Skip to content

Commit 2349642

Browse files
committed
Harden CSSX React tracking
1 parent 42ea9a4 commit 2349642

8 files changed

Lines changed: 505 additions & 57 deletions

File tree

docs/api/runtime.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ function App() {
153153
154154
Nested providers override outer `:root` variables for their subtree. Runtime
155155
`variables['--name']` still has higher priority than provider `:root` values.
156+
Compiled provider sheets may also use template interpolation inside `:root`
157+
custom property values, so a precompiled provider layer can pass dynamic theme
158+
tokens through `{ sheet, values }`.
156159
157160
Use `themed(tagName, Component)` for components that should be addressable by
158161
tag selectors in provider/global CSS. Class selectors remain global utilities

packages/css-to-rn/src/compiler.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ function compileRuleList (
176176
))
177177
continue
178178
}
179-
compileRootVariables(declarations, rootVariables, state, isTemplate)
179+
compileRootVariables(declarations, rootVariables, state)
180180
continue
181181
}
182182

@@ -213,8 +213,7 @@ function compileRuleList (
213213
function compileRootVariables (
214214
declarations: CssDeclarationAst[],
215215
rootVariables: Record<string, string>,
216-
state: CompileState,
217-
isTemplate: boolean
216+
state: CompileState
218217
): void {
219218
for (const declaration of declarations) {
220219
if (declaration.type !== 'declaration') continue
@@ -232,16 +231,6 @@ function compileRootVariables (
232231
}
233232

234233
const value = declaration.value ?? ''
235-
if (isTemplate && hasDynamicSlots(value)) {
236-
addDiagnostic(state, diagnostic(
237-
'UNSUPPORTED_INTERPOLATION_POSITION',
238-
'Interpolation is not supported inside :root variable declarations.',
239-
'error',
240-
positionOf(declaration)
241-
))
242-
continue
243-
}
244-
245234
rootVariables[property] = value
246235
}
247236
}

packages/css-to-rn/src/react/config.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const EMPTY_TRACKING_SHEET: CompiledCssSheet = {
8888
metadata: EMPTY_METADATA,
8989
diagnostics: []
9090
}
91+
const DYNAMIC_ROOT_SLOT_RE = /var\(\s*--__cssx_dynamic_(\d+)\s*\)/g
9192

9293
export function configureCssx (config: CssxReactConfig): void {
9394
setRuntimeConfig(config)
@@ -155,14 +156,10 @@ export function themed<P extends object> (
155156

156157
function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet {
157158
const trackerRef = useRef<TrackedCssxSheet | null>(null)
158-
159-
if (trackerRef.current == null) {
160-
trackerRef.current = new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options)
161-
} else {
162-
trackerRef.current.update(EMPTY_TRACKING_SHEET, options)
163-
}
164-
165-
const tracker = trackerRef.current
159+
const committedTracker = trackerRef.current
160+
const tracker = committedTracker?.matches(EMPTY_TRACKING_SHEET, options)
161+
? committedTracker
162+
: new TrackedCssxSheet(EMPTY_TRACKING_SHEET, options)
166163
const renderDependencies = tracker.startRender()
167164

168165
useSyncExternalStore(
@@ -173,6 +170,7 @@ function useCssxRenderTracker (options: CssxReactConfig): TrackedCssxSheet {
173170

174171
useCommitEffect(() => {
175172
tracker.commitRender(renderDependencies)
173+
trackerRef.current = tracker
176174
})
177175

178176
return tracker
@@ -223,7 +221,7 @@ function collectProviderStyle (
223221
if (isTrackedCssxSheet(input)) {
224222
const sheet = input.getSheet()
225223
layers.push({ sheet, cacheKey: input })
226-
collectRootVariables(sheet, scopedVariables)
224+
collectRootVariables(sheet, scopedVariables, input.getOptions().values)
227225
return
228226
}
229227

@@ -239,7 +237,7 @@ function collectProviderStyle (
239237
const sheet = typeof layer.sheet === 'string'
240238
? compileCss(layer.sheet, { mode: 'runtime' })
241239
: layer.sheet
242-
collectRootVariables(sheet, scopedVariables)
240+
collectRootVariables(sheet, scopedVariables, layer.values)
243241
}
244242
}
245243

@@ -271,9 +269,34 @@ function normalizeProviderStyleLayer (
271269

272270
function collectRootVariables (
273271
sheet: CompiledCssSheet,
274-
scopedVariables: Record<string, unknown>[]
272+
scopedVariables: Record<string, unknown>[],
273+
values: readonly unknown[] = []
275274
): void {
276-
if (sheet.rootVariables != null) scopedVariables.push(sheet.rootVariables)
275+
if (sheet.rootVariables != null) {
276+
scopedVariables.push(applyLayerValuesToRootVariables(sheet.rootVariables, values))
277+
}
278+
}
279+
280+
function applyLayerValuesToRootVariables (
281+
rootVariables: Record<string, string>,
282+
values: readonly unknown[]
283+
): Record<string, string> {
284+
if (values.length === 0) return rootVariables
285+
286+
const output: Record<string, string> = {}
287+
for (const name of Object.keys(rootVariables)) {
288+
const value = rootVariables[name]
289+
let valid = true
290+
const next = value.replace(DYNAMIC_ROOT_SLOT_RE, (_match, rawIndex: string) => {
291+
const interpolation = values[Number(rawIndex)]
292+
if (typeof interpolation === 'string') return interpolation
293+
if (typeof interpolation === 'number') return String(interpolation)
294+
valid = false
295+
return ''
296+
})
297+
if (valid) output[name] = next
298+
}
299+
return output
277300
}
278301

279302
function isProviderStyleLayer (value: unknown): value is CssxProviderStyleLayer {

packages/css-to-rn/src/react/hooks.ts

Lines changed: 137 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,25 @@ const useCommitEffect = typeof window === 'undefined'
3333
? useEffect
3434
: useLayoutEffect
3535
const CSS_VARIABLE_NAME_RE = /^--[A-Za-z0-9_-]+$/
36+
const EMPTY_METADATA = {
37+
hasVars: false,
38+
vars: [],
39+
hasMedia: false,
40+
hasViewportUnits: false,
41+
hasInterpolations: false,
42+
hasDynamicRuntimeDependencies: false,
43+
hasAnimations: false,
44+
hasTransitions: false
45+
}
46+
const EMPTY_LAYER_SHEET: CompiledCssSheet = {
47+
version: 1,
48+
id: 'cssx_empty_layer',
49+
contentHash: 'cssx_empty_layer',
50+
rules: [],
51+
keyframes: {},
52+
metadata: EMPTY_METADATA,
53+
diagnostics: []
54+
}
3655

3756
export type CssxLayerHookInput =
3857
| string
@@ -67,14 +86,10 @@ export function useCssxSheet (
6786
...context,
6887
...options
6988
}
70-
71-
if (trackerRef.current == null) {
72-
trackerRef.current = new TrackedCssxSheet(sheet, mergedOptions)
73-
} else {
74-
trackerRef.current.update(sheet, mergedOptions)
75-
}
76-
77-
const tracker = trackerRef.current
89+
const committedTracker = trackerRef.current
90+
const tracker = committedTracker?.matches(sheet, mergedOptions)
91+
? committedTracker
92+
: new TrackedCssxSheet(sheet, mergedOptions)
7893
const renderDependencies = tracker.startRender()
7994

8095
useSyncExternalStore(
@@ -85,6 +100,7 @@ export function useCssxSheet (
85100

86101
useCommitEffect(() => {
87102
tracker.commitRender(renderDependencies)
103+
trackerRef.current = tracker
88104
})
89105

90106
return tracker
@@ -119,30 +135,42 @@ export function useCssxLayer (
119135
input: CssxLayerHookInput,
120136
options: CssxReactConfig = {}
121137
): CssxLayerHookOutput {
122-
if (!input) return input
123-
124-
if (typeof input === 'string') return useRuntimeCss(input, options)
125-
if (input instanceof TrackedCssxSheet) return input
126-
if (isCompiledSheet(input)) return useCssxSheet(input, options)
138+
const context = useCssxConfig()
139+
const target = options.target ?? context.target
140+
const normalized = useMemo(
141+
() => normalizeLayerHookInput(input, target),
142+
[input, target]
143+
)
144+
const tracker = useCssxSheet(normalized.sheet, {
145+
...options,
146+
values: normalized.values
147+
})
127148

128-
if (isLayerObject(input)) {
129-
const sheet = input.sheet
130-
if (typeof sheet === 'string') {
131-
return {
132-
...input,
133-
sheet: useRuntimeCss(sheet, options)
149+
switch (normalized.kind) {
150+
case 'empty':
151+
return input as null | undefined | false
152+
case 'tracked':
153+
return input as CssxLayerHookOutput
154+
case 'layerTracked':
155+
return input as CssxLayerHookOutput
156+
case 'layerString': {
157+
const layerInput = input as {
158+
sheet: string | CompiledCssSheet | TrackedCssxSheet
159+
values?: readonly unknown[]
134160
}
161+
return {
162+
...layerInput,
163+
sheet: tracker
164+
} as CssxLayerHookOutput
135165
}
136-
if (sheet instanceof TrackedCssxSheet) return input as CssxLayerHookOutput
137-
if (isCompiledSheet(sheet)) {
138-
return useCssxSheet(sheet, {
139-
...options,
140-
values: input.values
141-
})
142-
}
166+
case 'compiled':
167+
case 'string':
168+
case 'layerCompiled':
169+
return tracker
170+
case 'unknown':
171+
default:
172+
return input as CssxLayerHookOutput
143173
}
144-
145-
return input as CssxLayerHookOutput
146174
}
147175

148176
export function useCssVariableRaw (
@@ -151,16 +179,20 @@ export function useCssVariableRaw (
151179
): string | undefined {
152180
assertCssVariableName(name)
153181
const context = useCssxRuntimeContext()
154-
const dependenciesRef = useRef<CssxDependencySnapshot>(createDependencySnapshot())
182+
const committedDependenciesRef = useRef<CssxDependencySnapshot>(createDependencySnapshot())
155183
const result = resolveCssVariableRaw(name, fallback, context.scopedVariables)
156-
dependenciesRef.current = createVariableDependencySnapshot(result)
184+
const renderDependencies = createVariableDependencySnapshot(result)
157185

158186
useSyncExternalStore(
159-
listener => subscribeRuntimeStore(listener, () => dependenciesRef.current),
187+
listener => subscribeRuntimeStore(listener, () => committedDependenciesRef.current),
160188
getRuntimeVersion,
161189
getRuntimeVersion
162190
)
163191

192+
useCommitEffect(() => {
193+
committedDependenciesRef.current = renderDependencies
194+
})
195+
164196
return result.value
165197
}
166198

@@ -208,6 +240,80 @@ function isLayerObject (value: unknown): value is {
208240
)
209241
}
210242

243+
type NormalizedLayerHookInput =
244+
| {
245+
kind: 'empty' | 'unknown' | 'tracked' | 'layerTracked'
246+
sheet: CompiledCssSheet
247+
values?: readonly unknown[]
248+
}
249+
| {
250+
kind: 'string' | 'compiled' | 'layerString' | 'layerCompiled'
251+
sheet: CompiledCssSheet
252+
values?: readonly unknown[]
253+
}
254+
255+
function normalizeLayerHookInput (
256+
input: CssxLayerHookInput,
257+
target: CssxReactConfig['target']
258+
): NormalizedLayerHookInput {
259+
if (!input) {
260+
return {
261+
kind: 'empty',
262+
sheet: EMPTY_LAYER_SHEET
263+
}
264+
}
265+
266+
if (typeof input === 'string') {
267+
return {
268+
kind: 'string',
269+
sheet: compileCss(input, { target })
270+
}
271+
}
272+
273+
if (input instanceof TrackedCssxSheet) {
274+
return {
275+
kind: 'tracked',
276+
sheet: EMPTY_LAYER_SHEET
277+
}
278+
}
279+
280+
if (isCompiledSheet(input)) {
281+
return {
282+
kind: 'compiled',
283+
sheet: input
284+
}
285+
}
286+
287+
if (isLayerObject(input)) {
288+
const sheet = input.sheet
289+
if (typeof sheet === 'string') {
290+
return {
291+
kind: 'layerString',
292+
sheet: compileCss(sheet, { target }),
293+
values: input.values
294+
}
295+
}
296+
if (sheet instanceof TrackedCssxSheet) {
297+
return {
298+
kind: 'layerTracked',
299+
sheet: EMPTY_LAYER_SHEET
300+
}
301+
}
302+
if (isCompiledSheet(sheet)) {
303+
return {
304+
kind: 'layerCompiled',
305+
sheet,
306+
values: input.values
307+
}
308+
}
309+
}
310+
311+
return {
312+
kind: 'unknown',
313+
sheet: EMPTY_LAYER_SHEET
314+
}
315+
}
316+
211317
function resolveCssVariableRaw (
212318
name: string,
213319
fallback?: unknown,

0 commit comments

Comments
 (0)