From 3a9c35d442b2485be2005d36efcb7ceb231ebb12 Mon Sep 17 00:00:00 2001 From: Hubert Bieszczad Date: Mon, 5 Oct 2026 09:19:17 +0200 Subject: [PATCH] fix: notify web listeners when new styles loaded --- CONTEXT.md | 2 +- packages/uniwind/src/core/web/cssListener.ts | 6 +++ .../tests/web/core/css-listener.test.ts | 45 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 packages/uniwind/tests/web/core/css-listener.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 02a38cea..7140a0de 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -81,7 +81,7 @@ Web runtime: - Web keeps styles in CSS and passes `{ $$css: true, tailwind: className }` through RNW style arrays. - `getWebStyles` uses a hidden DOM element to compute style values when a JS value is needed, such as color extraction or `useResolveClassNames`. -- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. +- `CSSListener` tracks active CSS rules and media queries, then notifies subscribers when class-dependent media rules change. After scanning newly discovered stylesheets, it emits a variables notification so JS-resolved styles refresh when CSS arrives after module initialization, including Metro web development startup. - `ScopedTheme` renders a `div` with the theme class and `display: contents` on web. - `LayoutDirection` renders a contents-style wrapper with `direction`/`dir` semantics so RTL/LTR variants can be scoped to a subtree. - `ScopedVariables` renders a `display: contents` wrapper and sets its variables as inline custom properties on that wrapper, so the real DOM cascade resolves `var(--name)` to the scoped value for every descendant (numbers become px). During JS reads (`getWebVariable` / `useResolveClassNames`) it also applies the variables to the hidden `dummyParent`, then clears them. diff --git a/packages/uniwind/src/core/web/cssListener.ts b/packages/uniwind/src/core/web/cssListener.ts index d5801c87..6e961c13 100644 --- a/packages/uniwind/src/core/web/cssListener.ts +++ b/packages/uniwind/src/core/web/cssListener.ts @@ -124,6 +124,7 @@ class CSSListenerBuilder { private initialize() { this.pendingInitialization = undefined this.pruneStaleRules() + let added = false for (const sheet of Array.from(document.styleSheets)) { // Skip already processed stylesheets @@ -149,6 +150,11 @@ class CSSListenerBuilder { this.processedStyleSheets.add(sheet) this.addMediaQueriesDeep(rules) + added = true + } + + if (added) { + UniwindListener.notify([StyleDependency.Variables]) } } diff --git a/packages/uniwind/tests/web/core/css-listener.test.ts b/packages/uniwind/tests/web/core/css-listener.test.ts new file mode 100644 index 00000000..8c07e8c7 --- /dev/null +++ b/packages/uniwind/tests/web/core/css-listener.test.ts @@ -0,0 +1,45 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { CSSListener } from '../../../src/core/web/cssListener' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +test('notifies class subscribers after discovering a stylesheet injected later', async () => { + vi.useFakeTimers() + vi.stubGlobal('requestIdleCallback', undefined) + + const style = document.createElement('style') + style.textContent = '.accent-late { accent-color: rgb(255, 0, 0); }' + + const listener = vi.fn(() => { + const rule = Array.from(CSSListener.activeRules).find(rule => rule.selectorText === '.accent-late') + return rule?.style.getPropertyValue('accent-color') + }) + const dispose = CSSListener.subscribeToClassName('accent-late', listener) + + try { + document.head.appendChild(style) + await Promise.resolve() + + expect(listener).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(50) + + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveLastReturnedWith('rgb(255, 0, 0)') + + // A scan with no new stylesheet should not invalidate resolved styles. + const meta = document.createElement('meta') + document.head.appendChild(meta) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(50) + expect(listener).toHaveBeenCalledTimes(1) + meta.remove() + } finally { + dispose() + style.remove() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(50) + } +})