Skip to content

fix(hydrate): keep options.layers alive for the app's lifetime - #78

Merged
jonlaing merged 1 commit into
mainfrom
fix/hydrate-scoped-layer-lifetime
Aug 3, 2026
Merged

fix(hydrate): keep options.layers alive for the app's lifetime#78
jonlaing merged 1 commit into
mainfrom
fix/hydrate-scoped-layer-lifetime

Conversation

@jonlaing

@jonlaing jonlaing commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Effect.provide(element, scopedLayer) uses scopedWith(scope => ...) internally (effect/internal/layer.js:516), which creates a fresh scope tied to the effect's lifetime. Because element functions return synchronously after building the DOM, every scoped resource — Navigation's popstate listener, SubscriptionRef PubSub subscribers, cache entries — was torn down before the user could interact. Browser back/forward, reactive updates, and anything relying on Effect.addFinalizer silently no-op'd.

Fix in packages/dom/src/Render/hydrate/index.ts: build the merged layers as a Context in hydrate's outer program scope (kept alive by Effect.never), then provide the context.

Investigation timeline

  • Symptom: browser back/forward didn't re-render (or fetch loader data) in SSG dev mode; forward via <Link> worked.
  • First fix attempt (PR fix(router): run popstate updates on app Runtime via runFork #76) — switched popstate handler from Effect.runSync to Runtime.runFork. Correct fix for that async-set problem, but symptom persisted.
  • Second suspicion — Layer.mergeAll(navLayer, dataProviderLayer) inside makeClientLayer. Reproduced in isolation → mergeAll delivered signal updates fine, exonerated.
  • Third stop — @effex/vite-plugin's stripStaticConfig transform. The workspace packages/vite-plugin/dist/index.js was stale (source fix from b1241f4 wasn't re-committed), producing Route.render(() => (fn)(undefined)). Portfolio's npm-installed @effex/vite-plugin@1.1.2 had the correct dist. Rebuilding the workspace dist unblocked further diagnosis.
  • Final layer — instrumented Navigation.make to log build & finalize. [FINALIZER] fired immediately after registration, before the user could click anything. Root cause: hydrate's inner Effect.provide was scoping the layer to the short-lived element.

Also fixed (found during investigation)

  • create-effex SSG template: called hydrate(App(), root) with no layers even though App uses Outlet/Link. Now passes Platform.makeClientLayer(router) via options.layers.
  • create-effex SPA template: passed { layers: ... } as a third arg to mount, which doesn't accept options — the arg was silently dropped and the returned Effect was never run. Now uses runApp(mount(...), { layer: ... }).
  • apps/docs/src/client.ts: was using the buggy Effect.provide(App(), navLayer) bake-in. Switched to options.layers.

Regression test

packages/dom/src/Render/hydrate/hydrate.test.ts adds a test that builds a scoped layer with a finalizer, hands its element to hydrate with options.layers, and asserts the finalizer has NOT fired 10ms after the initial render. Confirmed to fail against the pre-fix code and pass after.

Test plan

  • pnpm --filter @effex/dom test — 17/17 hydrate tests pass, regression test present
  • Reverted the fix locally → regression test fails as expected
  • Full packages/dom + packages/router suite: 465 passed, 2 skipped, 0 failed
  • apps/docs boots and renders (kept Navigation.makeLayer via options.layers)

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying effex with  Cloudflare Pages  Cloudflare Pages

Latest commit: 49af20d
Status: ✅  Deploy successful!
Preview URL: https://873b9dab.effex.pages.dev
Branch Preview URL: https://fix-hydrate-scoped-layer-lif.effex.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying effex-api with  Cloudflare Pages  Cloudflare Pages

Latest commit: 49af20d
Status: ✅  Deploy successful!
Preview URL: https://9c2f444c.effex-api.pages.dev
Branch Preview URL: https://fix-hydrate-scoped-layer-lif.effex-api.pages.dev

View logs

`Effect.provide(element, scopedLayer)` uses `scopedWith(scope => ...)`
internally, which scopes the layer to the effect's lifetime. Since
element functions return synchronously after building the DOM, every
scoped resource — Navigation's popstate listener, SubscriptionRef PubSub
subscribers, cache entries — was torn down before the user could
interact. Browser back/forward, reactive updates, and anything relying
on Effect.addFinalizer silently no-op'd.

Fixed by building the merged layers as a Context in hydrate's outer
program scope (kept alive by Effect.never) before providing.

Includes:
- packages/dom: the fix + regression test that would have caught this
- packages/create-effex: fix SSG template (was missing router layer
  entirely) and SPA template (passed options to `mount` which doesn't
  accept them, so nothing mounted).
- apps/docs: switch to `options.layers` pattern (was using the buggy
  `Effect.provide(App(), layer)` bake-in).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jonlaing
jonlaing force-pushed the fix/hydrate-scoped-layer-lifetime branch from 5614574 to 49af20d Compare August 3, 2026 20:58
@jonlaing
jonlaing merged commit a2b2f7d into main Aug 3, 2026
4 checks 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