Skip to content

fix(native): stop a read swallowing a notification, and share reactivity state across a dual-package split - #427

Open
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/reactivity-dual-package-guard
Open

fix(native): stop a read swallowing a notification, and share reactivity state across a dual-package split#427
YevheniiKotyrlo wants to merge 5 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/reactivity-dual-package-guard

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

Five fixes to the native reactivity primitive and the registries built on it.

The defects

  1. A read could swallow an observable notification. The cache advanced past the value a subsequent run()'s guard would read, so a subscriber that read at the wrong moment never learned the value had changed. This is the primitive every dark:, hover: and container-query rule is answered from, so a swallowed notification is a rule that silently stops re-evaluating.
  2. reactivity.ts had no dual-package guard at all. Its process-global state is now pinned to globalThis, so two copies of the module — the CJS and ESM builds loaded by different consumers — share one registry rather than each keeping a private one that the other's writes never reach.
  3. universalVariables was created and exported but never written. It now injects into its own family. Measured before fixing: universal correctly outranks root, so this was dead code plus an accidental ordering rather than a wrong value — the fix makes the intent real rather than repairing a live wrong answer.
  4. The jest harness leaked root and universal variables between tests. beforeEach cleared StyleCollection.styles but not rootVariables or universalVariables, so a vr entry survived into the next test. That is a harness defect that makes other people's tests pass for the wrong reason, and it already forced one :root test into a file of its own.
  5. A :root variable test now reaches the runtime registry rather than asserting only the compiled shape.

Evidence

Each fix has a red-then-green test. Fix 1's is the interesting one: it drives the primitive directly rather than through a rule, because a notification swallowed inside a rule evaluation is indistinguishable from a rule that legitimately did not match.

Fix 4 is worth calling out as a harness change rather than a product change — it makes some existing tests stricter, and any that were relying on the leak are now honest.

yarn typecheck and yarn lint exit 0. The 3 remaining failures are the known Windows babel-plugin-tester baseline (an unrewritten relative require("../View")), present on main and unrelated.

`observable()` used `value` for two jobs: the cache a reader gets back,
and the yardstick `run()`/`set()` compare against to decide whether
observers still need telling. `get()` refreshes the cache, so a read that
landed between a write and its notification moved the yardstick onto the
new value and the change was then treated as already delivered - the
subscriber was never notified.

Two interleavings reach this through the public API with no timing
involved. Inside a batch, a write queues the derived observable's effect
and any read before the flush advances the cache, so the flush finds them
equal and returns. Unbatched, a co-observer registered on the source
ahead of the derived observable's own effect reads the derived value
during the fan-out with the same result.

Track the published value separately: only `notify()` advances it, so a
read can no longer cancel a notification. `get()` publishes when nobody
is subscribed, since no observer can be owed a value produced while the
observer set was empty - without that, the first dependency change would
notify for a value the subscriber had already read.

`set()` collapses to a single guard. The static branch's comparison was
provably identical to the dynamic one (a static observable has `didInit`
from construction, so `get()` never refreshes its cache and the two
values cannot diverge), and two guards meant one of them could never be
made to fail.

The test drives every gap a read can land in around a write, batched and
unbatched, over a write sequence that returns to an already-seen value
and includes a write that changes nothing. It asserts both halves: a
settled write leaves no subscriber stale, and no read manufactures a
notification.
`package.json`'s `exports` map sends `import` to `dist/module/**` and
`require` to `dist/commonjs/**`. Metro resolves per requesting module, so
one app can evaluate this module twice, and every piece of state it owns
was duplicated by that: two `Dimensions` listeners writing two `vw`s, two
`colorScheme`s, two container families, and two `observableBatch`es. A
subscriber registered through one copy never heard a write made through
the other, and a batch opened by `StyleCollection.inject` on one copy did
not capture writes made through the other.

The state is pinned as one object rather than a global per export,
matching `style-collection.ts`. The pieces are mutually coupled - `vw`
and `vh` derive from `dimensions`, and the listener that writes them does
so through `observableBatch` - so a per-export guard invites a later edit
that shares some and not others, and a half-shared graph is harder to
diagnose than an unshared one. Building it in one initializer also makes
the `Dimensions` and `Appearance` registrations part of what runs once.

The pure exports stay unpinned: `observable`, `family`, `weakFamily` and
`cleanupEffect` close over no process state, so a second copy of them is
harmless. `VAR_SYMBOL` is interned by `Symbol.for` already.

The test reproduces the hazard rather than describing it: two
`jest.resetModules()` + `import()` pairs evaluate the source twice
against one `globalThis`, which is the same shape as the two builds. It
asserts the two copies are genuinely distinct first - `observable`
differs between them - so the sharing assertions cannot pass by the
module simply being cached. The state census is read off the guarded
object, so state added later is covered without editing the test.
`* { --x: ... }` compiles to `vu` and `:root { --x: ... }` compiles to
`vr`, and the resolver consults `universalVariables` before
`rootVariables`. The injector wrote both into `rootVariables`, so
`universalVariables` was never populated and the resolver's universal
branch was dead.

That is not only dead code. `set` replaces a variable's whole value list,
and the `vu` loop runs after the `vr` loop, so a universal declaration
overwrote the root declaration of the same name. When the universal
declaration sits behind a media query that does not match, it resolves to
nothing and the root value it replaced is gone:

    :root { --my-var: #123456; }
    @media (min-width: 99999px) { * { --my-var: #abcdef; } }

resolved to no colour at all instead of falling back to `#123456`.

Injecting `vu` into `universalVariables` keeps the two censuses apart, so
each resolves independently and the resolver's existing order decides
between them.

That order is deliberate and now asserted: `*` outranks `:root` for any
non-root element, because a `*` declaration applies to the element
directly while a `:root` declaration only reaches it by inheritance. It
held before only because one census was always empty, so swapping the two
reads broke nothing. The tests cover both directions of the fallback and
both rankings, and a swap of the resolver's two reads now fails.
The harness cleared `StyleCollection.styles` between tests but left the
`:root` and `*` variable families alone. Both are process-global, so a
`vr` or `vu` entry injected by one test resolved in every test after it.
Injecting universal variables into their own family makes this worse,
since there are now two registries carrying state nobody clears.

`resetGlobalVariables` clears both and re-applies the variables the
runtime declares for itself. Those cannot simply survive the clear -
`__rn-css-rem` backs every relative length, so dropping it would resolve
every `em` and `rem` to nothing - so seeding moves into a function that
both module init and the reset call.

No existing test changes behaviour: 1097 passing before, 1097 after, with
the same three pre-existing babel failures.

The tests drive the reset directly rather than relying on a previous test
having dirtied the registries, so each passes alone and in any order. The
end-to-end case declares its variable twice, once behind a media query
that cannot match. A `:root` variable with a single declaration is folded
into the rule by the compiler, so a test written the obvious way never
reaches the registry at all and holds whatever the reset does.
A `:root` custom property with exactly one declaration is folded straight
into the consuming rule by the compiler - the stylesheet carries no `vr`
entry at all, and `color: var(--my-var)` compiles to `color: #123456`. A
test written that way asserts the compiler's constant folding and never
reaches `rootVariables`, so it holds whatever the registry does.

Declaring the variable a second time behind a media query that cannot
match keeps it dynamic. Removing the resolver's `rootVariables` branch
now fails this test; before, it passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant