@barefootjs/client 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reactive.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * Inspired by SolidJS signals.
6
6
  */
7
7
 
8
+ import { BF_SEAM_PUSH_SEARCH } from '@barefootjs/shared'
9
+
8
10
  /**
9
11
  * Phantom brand for compile-time reactivity detection.
10
12
  * The compiler checks for the '__reactive' property via TypeChecker
@@ -597,3 +599,131 @@ export function createMemo<T>(fn: () => T, __bfId?: string): Memo<T> {
597
599
  return value
598
600
  }
599
601
 
602
+
603
+ // ---------------------------------------------------------------------------
604
+ // Request-scoped environment signals (router v0.5, spec/router.md "The wedge")
605
+ // ---------------------------------------------------------------------------
606
+ //
607
+ // An environment signal is ambient request/browser state — the query string,
608
+ // later cookies — that is correct per-request under SSR (read from the adapter's
609
+ // per-request context, never a process-wide module global, which would race) and
610
+ // reactive on the client (a query-only navigation updates it with no swap and no
611
+ // re-hydration; islands reconcile fine-grained). It rides the `Reactive<>` brand,
612
+ // so the compiler's reactivity analysis wires DOM updates with no new feature.
613
+ //
614
+ // These live HERE, in the single physical `@barefootjs/client/reactive` module,
615
+ // for the same reason the signal primitives do: both `@barefootjs/client` and
616
+ // the `/runtime` entry re-export them from this one module, so a page has ONE
617
+ // `searchParams` signal instance regardless of which entry an island imports
618
+ // from. A relative copy bundled into each entry would create two disconnected
619
+ // signals — the #1910 failure (the router would push into one while an island
620
+ // reads the other).
621
+ //
622
+ // No import-time side effect: the underlying signal is created lazily on first
623
+ // read and the router push seam is installed there (not at module top-level), so
624
+ // reading is the only thing that materialises anything.
625
+
626
+ /**
627
+ * SSR reader for request-scoped environment signals, keyed by env id
628
+ * (`'search'` today; `'cookie'` etc. later). Injected by an adapter / host so
629
+ * each value resolves per-request inside the host's async context — no shared
630
+ * mutable server state. ONE keyed reader serves every env signal, so a new
631
+ * signal needs no new seam or setter.
632
+ */
633
+ let serverEnvReader: ((key: string) => string | undefined) | null = null
634
+
635
+ /**
636
+ * Adapter/host hook: teach `@barefootjs/client` how to read the current
637
+ * request's environment values during SSR. The reader receives an env key
638
+ * (`'search'`, …) and returns the raw value, or `undefined` to defer (to the
639
+ * `globalThis` seam, else the empty default). Call once with a reader that
640
+ * resolves per-request.
641
+ */
642
+ export function __bfSetServerEnvReader(
643
+ reader: ((key: string) => string | undefined) | null,
644
+ ): void {
645
+ serverEnvReader = reader
646
+ }
647
+
648
+ /**
649
+ * Resolve a request-scoped env value during SSR: the reader set via
650
+ * {@link __bfSetServerEnvReader}, else a `globalThis.__bf_serverEnvReader` seam
651
+ * — so a host can wire request-scoped SSR *without* importing
652
+ * `@barefootjs/client` (the server-side analogue of the `window.__bf_*` client
653
+ * seams). `undefined` when no reader resolves the key.
654
+ */
655
+ function resolveServerEnv(key: string): string | undefined {
656
+ if (serverEnvReader) {
657
+ const v = serverEnvReader(key)
658
+ if (v !== undefined) return v
659
+ }
660
+ const seam = (
661
+ globalThis as unknown as { __bf_serverEnvReader?: (key: string) => string | undefined }
662
+ ).__bf_serverEnvReader
663
+ return typeof seam === 'function' ? seam(key) : undefined
664
+ }
665
+
666
+ /**
667
+ * Build a request-scoped reactive environment signal, keyed by `key` — the env
668
+ * id the SSR reader resolves (`'search'`, …). Internal: only concrete instances
669
+ * (`searchParams`, …) are exported.
670
+ */
671
+ function createEnvSignal<T>(
672
+ key: string,
673
+ readClient: () => string,
674
+ parse: (raw: string) => T,
675
+ pushSeam: string,
676
+ ): Reactive<() => T> {
677
+ let getRaw: (() => string) | null = null
678
+
679
+ function ensureClientSignal(): string {
680
+ if (!getRaw) {
681
+ const [get, set] = createSignal(readClient())
682
+ getRaw = get
683
+ // Install the router push seam inside the lazily-invoked accessor (not at
684
+ // module top-level), so reading is the only thing with an effect.
685
+ const w = window as unknown as Record<string, (next: string) => void>
686
+ // `set` already bails on `Object.is` equality, so no equality guard is
687
+ // needed — and a `get()` here would register a spurious dependency if a
688
+ // caller ever pushed from inside an effect.
689
+ w[pushSeam] = (next: string) => {
690
+ set(next)
691
+ }
692
+ }
693
+ return getRaw()
694
+ }
695
+
696
+ return (() => {
697
+ if (typeof window === 'undefined') {
698
+ // SSR: resolve per-call inside the host's request context. Never cache a
699
+ // module-level signal — it would be a process-wide global that races.
700
+ return parse(resolveServerEnv(key) ?? '')
701
+ }
702
+ return parse(ensureClientSignal())
703
+ }) as Reactive<() => T>
704
+ }
705
+
706
+ /**
707
+ * Reactive read of the current query string as `URLSearchParams`.
708
+ *
709
+ * ```tsx
710
+ * const sort = createMemo(() => searchParams().get('sort') ?? 'recent')
711
+ * ```
712
+ *
713
+ * A same-route, query-only navigation (`/list?sort=price`) driven by
714
+ * `@barefootjs/router` updates this signal and the URL **without a swap or
715
+ * re-hydration**. On the server it reflects the current request's query.
716
+ *
717
+ * Reactivity is **router-driven**: the signal is seeded once on first read and
718
+ * thereafter updated only through the `window.__bf_pushSearch` seam, which
719
+ * `startRouter()` drives (including on `popstate`). Without the router running
720
+ * — or after a non-router `history` change — the value is read-once; the seam
721
+ * name is shared with `@barefootjs/router` via `BF_SEAM_PUSH_SEARCH`, so the
722
+ * installer (here) and the pusher agree by construction.
723
+ */
724
+ export const searchParams = createEnvSignal(
725
+ 'search',
726
+ () => window.location.search,
727
+ (raw) => new URLSearchParams(raw),
728
+ BF_SEAM_PUSH_SEARCH,
729
+ )
@@ -43,6 +43,7 @@ import { hydratedScopes } from './hydration-state.ts'
43
43
  import { registerComponent } from './registry.ts'
44
44
  import { registerTemplate } from './template.ts'
45
45
  import { BF_SCOPE, BF_PROPS, BF_HOST, BF_SCOPE_COMMENT_PREFIX } from '@barefootjs/shared'
46
+ import { createRoot } from '@barefootjs/client/reactive'
46
47
  import type { ComponentDef } from './types.ts'
47
48
 
48
49
  /**
@@ -185,6 +186,98 @@ export function flushHydration(): void {
185
186
  walkAllInDocumentOrder()
186
187
  }
187
188
 
189
+ /**
190
+ * Re-hydrate only the scopes inside `root` (and `root` itself), in
191
+ * document order. Unlike `rehydrateAll()` — which schedules a walk over
192
+ * the *whole document* — this is a synchronous, subtree-scoped walk: its
193
+ * cost is O(scopes in `root`), not O(document).
194
+ *
195
+ * Intended for a client router that has just swapped a content region:
196
+ * the component registry is already populated from the initial load, so
197
+ * no deferral is needed; only the freshly inserted islands need to init.
198
+ */
199
+ export function rehydrateScope(root: Element): void {
200
+ if (typeof document === 'undefined') return
201
+ // The TreeWalker visits descendants only, so handle the root itself first.
202
+ hydrateElementScope(root)
203
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT)
204
+ while (walker.nextNode()) {
205
+ const node = walker.currentNode
206
+ if (node.nodeType === Node.ELEMENT_NODE) {
207
+ hydrateElementScope(node as Element)
208
+ } else if (node.nodeType === Node.COMMENT_NODE) {
209
+ hydrateCommentScope(node as Comment)
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Dispose every hydrated scope inside `root` (and `root` itself): runs
216
+ * each scope's `createRoot` dispose fn, tearing down its effects, memos,
217
+ * and `onCleanup` callbacks. Used by a client router before it removes a
218
+ * content region, so the outgoing islands release timers/listeners/
219
+ * subscriptions instead of leaking.
220
+ *
221
+ * Also resets the per-scope hydration marks so the *same* DOM nodes could
222
+ * be re-hydrated if they are ever re-inserted:
223
+ * - element scopes: their `hydratedScopes` entry (via `disposeOneScope`),
224
+ * - `bf-h` child scopes: their `hydratedScopes` entry (they have no own
225
+ * disposer — their reactive graph is owned by an ancestor root being
226
+ * disposed here),
227
+ * - comment scopes: the `__bfInitialized` flag on the `<!--bf-scope:-->`
228
+ * anchor (otherwise `hydrateCommentScope` short-circuits on the stale
229
+ * flag and never re-initializes the scope).
230
+ */
231
+ export function disposeScope(root: Element): void {
232
+ if (typeof document === 'undefined') return
233
+ disposeOneScope(root)
234
+ clearChildScopeMark(root)
235
+ const walker = document.createTreeWalker(
236
+ root,
237
+ NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT,
238
+ )
239
+ while (walker.nextNode()) {
240
+ const node = walker.currentNode
241
+ if (node.nodeType === Node.ELEMENT_NODE) {
242
+ disposeOneScope(node as Element)
243
+ clearChildScopeMark(node as Element)
244
+ } else if (node.nodeType === Node.COMMENT_NODE) {
245
+ clearCommentScopeFlag(node as Comment)
246
+ }
247
+ }
248
+ }
249
+
250
+ function disposeOneScope(el: Element): void {
251
+ const dispose = scopeDisposers.get(el)
252
+ if (dispose) {
253
+ scopeDisposers.delete(el)
254
+ hydratedScopes.delete(el)
255
+ dispose()
256
+ }
257
+ }
258
+
259
+ /**
260
+ * `bf-h` child scopes are initialized via `initChild` (not the walker), so
261
+ * they carry a `hydratedScopes` mark but no own disposer — their effects
262
+ * belong to an ancestor root that `disposeScope` is tearing down. Clear the
263
+ * mark so the same node can re-init if re-inserted.
264
+ */
265
+ function clearChildScopeMark(el: Element): void {
266
+ if (el.hasAttribute(BF_HOST)) hydratedScopes.delete(el)
267
+ }
268
+
269
+ /**
270
+ * Comment-rooted scopes flag their init on the `<!--bf-scope:-->` comment
271
+ * node (`__bfInitialized`); the proxy element's `hydratedScopes` entry and
272
+ * disposer are cleared by `disposeOneScope`, but the comment flag must be
273
+ * reset here or re-hydration short-circuits.
274
+ */
275
+ function clearCommentScopeFlag(comment: Comment): void {
276
+ if (!comment.nodeValue?.startsWith(BF_SCOPE_COMMENT_PREFIX)) return
277
+ const flagged = comment as unknown as { __bfInitialized?: boolean }
278
+ if (flagged.__bfInitialized) flagged.__bfInitialized = false
279
+ }
280
+
188
281
  /**
189
282
  * Single document-order walk visiting element scopes (`[bf-s]`) and
190
283
  * comment scopes (`<!--bf-scope:Name_xxx-->`) interleaved by their
@@ -232,10 +325,29 @@ function parseProps(json: string | null, where: string): Record<string, unknown>
232
325
  }
233
326
  }
234
327
 
328
+ /**
329
+ * Per-scope disposal registry. Each scope's `init` runs inside a
330
+ * `createRoot`, so all the effects/memos it (and its `initChild`-mounted
331
+ * descendants) create are owned by one root. `disposeScope(root)` calls
332
+ * the stored dispose fn to tear them down — enabling a client router to
333
+ * release the islands leaving the page precisely, instead of leaking
334
+ * timers/listeners or relying on GC (see `disposeScope`).
335
+ *
336
+ * Keyed by the scope element; a `WeakMap` so detached scopes are
337
+ * reclaimed even if `disposeScope` is never called.
338
+ */
339
+ const scopeDisposers = new WeakMap<Element, () => void>()
340
+
235
341
  function runInit(scope: Element, def: ComponentDef, props: Record<string, unknown>): void {
236
342
  const prevScope = setCurrentScope(scope)
237
343
  try {
238
- def.init(scope, props)
344
+ // Wrap in createRoot so the scope's reactive graph has an owner that
345
+ // can be disposed later. This is additive: nothing disposes the root
346
+ // unless `disposeScope` is called, so existing lifetimes are unchanged.
347
+ createRoot((dispose) => {
348
+ scopeDisposers.set(scope, dispose)
349
+ def.init(scope, props)
350
+ })
239
351
  } finally {
240
352
  setCurrentScope(prevScope)
241
353
  }
@@ -22,6 +22,13 @@ export {
22
22
  beginTurn,
23
23
  endTurn,
24
24
  __bfReportOutput,
25
+ // Request-scoped env signal (router v0.5). The compiler emits island client JS
26
+ // that imports `searchParams` from `@barefootjs/client/runtime`; re-exporting
27
+ // it from the shared reactive module (same as the signal primitives above)
28
+ // means this entry and the main `@barefootjs/client` entry resolve to ONE
29
+ // signal instance — no second copy to disconnect from router pushes.
30
+ searchParams,
31
+ __bfSetServerEnvReader,
25
32
  type Reactive,
26
33
  type Signal,
27
34
  type Memo,
@@ -92,7 +99,7 @@ export { styleToCss } from './style.ts'
92
99
 
93
100
  // Runtime helpers
94
101
  export { findScope, find, $, $c, $t, qsa, qsaChildScope, qsaChildScopes, cssEscape } from './query.ts'
95
- export { hydrate, rehydrateAll, flushHydration, getRegisteredDef } from './hydrate.ts'
102
+ export { hydrate, rehydrateAll, rehydrateScope, disposeScope, flushHydration, getRegisteredDef } from './hydrate.ts'
96
103
  export { registerComponent, getComponentInit, initChild, upsertChild } from './registry.ts'
97
104
  export { insert, type BranchConfig, type BranchTemplateResult } from './insert.ts'
98
105
  export { __bfSlot } from './branch-slot.ts'
@@ -13,8 +13,14 @@
13
13
  * 3. This module swaps fallback → resolved content and triggers hydration.
14
14
  */
15
15
 
16
- import { BF_ASYNC, BF_ASYNC_RESOLVE } from '@barefootjs/shared'
17
- import { rehydrateAll } from './hydrate.ts'
16
+ import {
17
+ BF_ASYNC,
18
+ BF_ASYNC_RESOLVE,
19
+ BF_SEAM_DISPOSE_WITHIN,
20
+ BF_SEAM_HYDRATE,
21
+ BF_SEAM_HYDRATE_WITHIN,
22
+ } from '@barefootjs/shared'
23
+ import { rehydrateAll, rehydrateScope, disposeScope } from './hydrate.ts'
18
24
 
19
25
  /**
20
26
  * Swap a streaming fallback placeholder with its resolved content.
@@ -52,7 +58,11 @@ export function __bf_swap(id: string): void {
52
58
  * Makes `__bf_swap` available as `window.__bf_swap` so that inline
53
59
  * `<script>__bf_swap("a0")</script>` tags in streaming chunks can call it.
54
60
  *
55
- * Also exposes `window.__bf_hydrate` for manual re-hydration triggers.
61
+ * Also exposes re-hydration / disposal seams for an out-of-tree client
62
+ * router (`@barefootjs/router`):
63
+ * - `window.__bf_hydrate` — re-hydrate the whole document
64
+ * - `window.__bf_hydrate_within` — re-hydrate only a swapped subtree
65
+ * - `window.__bf_dispose_within` — dispose the islands in a subtree
56
66
  *
57
67
  * Call this once, early in the page (before any streaming chunks arrive).
58
68
  */
@@ -61,5 +71,9 @@ export function setupStreaming(): void {
61
71
 
62
72
  const w = window as unknown as Record<string, unknown>
63
73
  w.__bf_swap = __bf_swap
64
- w.__bf_hydrate = rehydrateAll
74
+ // Seam names shared with `@barefootjs/router` via `@barefootjs/shared` so the
75
+ // installer (here) and the reader (the router) can never drift apart.
76
+ w[BF_SEAM_HYDRATE] = rehydrateAll
77
+ w[BF_SEAM_HYDRATE_WITHIN] = rehydrateScope
78
+ w[BF_SEAM_DISPOSE_WITHIN] = disposeScope
65
79
  }