@fundamental-engine/elements 0.9.1 → 0.9.3

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * SSR pre-registration queue for shadow hosts (docs/engine-reference/shadow-dom.md §31.10).
3
+ *
4
+ * ## The timing problem
5
+ *
6
+ * Custom elements dispatch composed `field:register-body` events (via {@link FieldController}) from
7
+ * their `connectedCallback`. The singleton `<field-root>` field only listens for those events *after*
8
+ * it boots — `start()` → `createBrowserField` → `host.onBodyEvent(REGISTER_BODY, …)` on `document`.
9
+ *
10
+ * On a **server-rendered** page the custom elements already exist in the DOM at hydration, and they
11
+ * upgrade in document order. A body element earlier in the document than `<field-root>` (or whose
12
+ * definition loads first) fires `connectedCallback` — and its registration event — **before** the
13
+ * field has wired its listeners. A one-shot `dispatchEvent` with no listener is simply lost, so the
14
+ * body never joins the field: it renders as inert HTML.
15
+ *
16
+ * ## The queue (§31.10)
17
+ *
18
+ * Importing `@fundamental-engine/elements` installs a **capturing** document listener at module load
19
+ * (this file is part of the package's side-effecting entry, so any SSR/hydration bundle that pulls in
20
+ * the custom elements installs the queue at exactly the right moment — before any element upgrades).
21
+ * While no field is live it **buffers** each register / unregister / update event, keyed by element
22
+ * (dedupe per §31.10 — last write wins, so an `unregister` supersedes a pending `register` and no
23
+ * stale body is replayed). When a field boots it calls {@link flushPreRegistrationQueue}, which
24
+ * **replays** the buffered events on their source elements; they bubble (composed) to `document`,
25
+ * where the field's now-wired, idempotent listeners register them. Component mount order no longer
26
+ * has to be perfect.
27
+ *
28
+ * ## Why replay rather than a private body set
29
+ *
30
+ * Replaying the original events reuses the entire existing registration path (the field's
31
+ * `onRegister`/`onUnregister`/`onUpdate` handlers, the idempotent element-keyed `ShadowRegistry`,
32
+ * the coalesced rescan) with zero duplicated logic and no new public surface — it is pure lifecycle
33
+ * plumbing. Registration is idempotent (element-keyed), so a client-only page whose field boots
34
+ * before any element (the common case: nothing is ever buffered) behaves exactly as before, and the
35
+ * live path is untouched: once a field is active, events flow straight through and are never buffered.
36
+ *
37
+ * SSR-safe: no bare `document` / `window` access at module load. The install is guarded and becomes a
38
+ * no-op under Node (no `document`), so importing this on the server never throws.
39
+ */
40
+ import { REGISTER_BODY, UNREGISTER_BODY, UPDATE_BODY } from '@fundamental-engine/core';
41
+ /** The body registration event names the queue intercepts (all three `composed` shadow events). */
42
+ const QUEUED_EVENTS = [REGISTER_BODY, UNREGISTER_BODY, UPDATE_BODY];
43
+ /**
44
+ * Buffered registration events, keyed by their source element so a burst per element collapses to its
45
+ * latest intent (register → update → unregister). Dedupe by element per §31.10: replaying only the
46
+ * last event avoids re-registering a body a later `unregister` already retired.
47
+ */
48
+ const pending = new Map();
49
+ /**
50
+ * How many fields are currently live. While zero, early registration events are buffered; once a
51
+ * field is active the events reach it directly, so the queue stops buffering (and, during a flush's
52
+ * replay, does not re-buffer the events it just dispatched — the field is active by then).
53
+ */
54
+ let activeFields = 0;
55
+ /** Installed once, lazily, on the first `<field-root>`/`<field-field>` construction (guarded for SSR). */
56
+ let installed = false;
57
+ /** Capture an early registration event into the queue (only while no field is live). */
58
+ function capture(e) {
59
+ if (activeFields > 0)
60
+ return; // a field is live — it takes the event directly; don't buffer.
61
+ const detail = e.detail;
62
+ if (!detail?.element)
63
+ return;
64
+ // last write wins: a later unregister/update for the same element supersedes an earlier register.
65
+ pending.set(detail.element, { type: e.type, detail });
66
+ }
67
+ /**
68
+ * Install the capturing document listeners — idempotent, SSR-guarded. Called from the custom
69
+ * element's constructor so it runs on the client before the element upgrades its peers, and never on
70
+ * the server (no `document`). Capturing (`{ capture: true }`) so the queue sees the event on the way
71
+ * down, independent of any later-added bubble-phase field listener.
72
+ */
73
+ export function installPreRegistrationQueue() {
74
+ if (installed || typeof document === 'undefined')
75
+ return;
76
+ installed = true;
77
+ for (const type of QUEUED_EVENTS) {
78
+ document.addEventListener(type, capture, { capture: true });
79
+ }
80
+ }
81
+ /**
82
+ * Mark that a field has become live. The first live field is what makes subsequent registration
83
+ * events skip the queue and reach the field directly. Paired with {@link markFieldInactive}.
84
+ */
85
+ export function markFieldActive() {
86
+ activeFields++;
87
+ }
88
+ /**
89
+ * Mark that a live field has torn down. When the last field goes away the queue resumes buffering, so
90
+ * an element that (re)connects during a field-less window is captured for the next field that boots.
91
+ */
92
+ export function markFieldInactive() {
93
+ if (activeFields > 0)
94
+ activeFields--;
95
+ }
96
+ /**
97
+ * Replay every buffered registration event on its source element, then clear the buffer. Called by a
98
+ * field immediately after it wires its body-event listeners, so the events bubble (composed) to the
99
+ * document and the field registers them through its normal, idempotent path. Safe to call with an
100
+ * empty queue (the common client-only case) — it does nothing.
101
+ */
102
+ export function flushPreRegistrationQueue() {
103
+ if (!pending.size)
104
+ return;
105
+ // snapshot + clear first: dispatching runs the field's synchronous handlers, and clearing up front
106
+ // means a re-entrant register during that dispatch buffers cleanly for the next flush.
107
+ const queued = [...pending.values()];
108
+ pending.clear();
109
+ for (const { type, detail } of queued) {
110
+ // only replay for an element still in the document — an element that connected and disconnected
111
+ // during the field-less window (unregister superseded register) is already gone; and a stale
112
+ // register whose element never made it into the DOM must not resurrect a body.
113
+ if (!detail.element.isConnected)
114
+ continue;
115
+ detail.element.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail }));
116
+ }
117
+ }
118
+ /** Test-only: the number of events currently buffered (0 once drained). */
119
+ export function pendingRegistrationCount() {
120
+ return pending.size;
121
+ }
122
+ /** Test-only: reset all module state (queue, active-field count, install flag) between tests. */
123
+ export function resetPreRegistrationQueue() {
124
+ pending.clear();
125
+ activeFields = 0;
126
+ if (installed && typeof document !== 'undefined') {
127
+ for (const type of QUEUED_EVENTS)
128
+ document.removeEventListener(type, capture, { capture: true });
129
+ }
130
+ installed = false;
131
+ }
132
+ /** Whether the queue is currently buffering (no field live). Test/introspection helper. */
133
+ export function isBuffering() {
134
+ return activeFields === 0;
135
+ }
136
+ //# sourceMappingURL=preregistration-queue.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preregistration-queue.js","sourceRoot":"","sources":["../src/preregistration-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAA2B,MAAM,0BAA0B,CAAC;AAEhH,mGAAmG;AACnG,MAAM,aAAa,GAAG,CAAC,aAAa,EAAE,eAAe,EAAE,WAAW,CAAU,CAAC;AAE7E;;;;GAIG;AACH,MAAM,OAAO,GAAG,IAAI,GAAG,EAA6D,CAAC;AAErF;;;;GAIG;AACH,IAAI,YAAY,GAAG,CAAC,CAAC;AAErB,0GAA0G;AAC1G,IAAI,SAAS,GAAG,KAAK,CAAC;AAEtB,wFAAwF;AACxF,SAAS,OAAO,CAAC,CAAQ;IACvB,IAAI,YAAY,GAAG,CAAC;QAAE,OAAO,CAAC,+DAA+D;IAC7F,MAAM,MAAM,GAAI,CAAqC,CAAC,MAAM,CAAC;IAC7D,IAAI,CAAC,MAAM,EAAE,OAAO;QAAE,OAAO;IAC7B,kGAAkG;IAClG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC;AACxD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,2BAA2B;IACzC,IAAI,SAAS,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO;IACzD,SAAS,GAAG,IAAI,CAAC;IACjB,KAAK,MAAM,IAAI,IAAI,aAAa,EAAE,CAAC;QACjC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9D,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe;IAC7B,YAAY,EAAE,CAAC;AACjB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB;IAC/B,IAAI,YAAY,GAAG,CAAC;QAAE,YAAY,EAAE,CAAC;AACvC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,yBAAyB;IACvC,IAAI,CAAC,OAAO,CAAC,IAAI;QAAE,OAAO;IAC1B,mGAAmG;IACnG,uFAAuF;IACvF,MAAM,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACrC,OAAO,CAAC,KAAK,EAAE,CAAC;IAChB,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,MAAM,EAAE,CAAC;QACtC,gGAAgG;QAChG,6FAA6F;QAC7F,+EAA+E;QAC/E,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,WAAW;YAAE,SAAS;QAC1C,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;IACjG,CAAC;AACH,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,wBAAwB;IACtC,OAAO,OAAO,CAAC,IAAI,CAAC;AACtB,CAAC;AAED,iGAAiG;AACjG,MAAM,UAAU,yBAAyB;IACvC,OAAO,CAAC,KAAK,EAAE,CAAC;IAChB,YAAY,GAAG,CAAC,CAAC;IACjB,IAAI,SAAS,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACjD,KAAK,MAAM,IAAI,IAAI,aAAa;YAAE,QAAQ,CAAC,mBAAmB,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;IACnG,CAAC;IACD,SAAS,GAAG,KAAK,CAAC;AACpB,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,WAAW;IACzB,OAAO,YAAY,KAAK,CAAC,CAAC;AAC5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundamental-engine/elements",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Web-component keystone for Fundamental — the <field-root> custom element + declarative data-body bodies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -48,9 +48,9 @@
48
48
  "access": "public"
49
49
  },
50
50
  "dependencies": {
51
- "@fundamental-engine/dom": "0.9.1",
52
- "@fundamental-engine/core": "0.9.1",
53
- "@fundamental-engine/vanilla": "0.9.1"
51
+ "@fundamental-engine/dom": "0.9.3",
52
+ "@fundamental-engine/vanilla": "0.9.3",
53
+ "@fundamental-engine/core": "0.9.3"
54
54
  },
55
55
  "devDependencies": {
56
56
  "typescript": "^5.9.3"