@webjsdev/core 0.7.49 → 0.7.51

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.
Files changed (61) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +21 -21
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/attribute-reader.js +178 -0
  12. package/src/component/lifecycle.js +1481 -0
  13. package/src/component/reactive.js +171 -0
  14. package/src/component/server-element.js +248 -0
  15. package/src/component.d.ts +10 -0
  16. package/src/component.js +2 -1931
  17. package/src/html-entities.js +2179 -0
  18. package/src/render-client/parts.js +2007 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2939
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +923 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2334
  30. package/src/router-client/anchors.js +44 -0
  31. package/src/router-client/boundaries.js +221 -0
  32. package/src/router-client/constants.js +187 -0
  33. package/src/router-client/diagnostics.js +377 -0
  34. package/src/router-client/dom-differ.js +709 -0
  35. package/src/router-client/dom-parse.js +150 -0
  36. package/src/router-client/events.js +139 -0
  37. package/src/router-client/fetch-apply.js +387 -0
  38. package/src/router-client/form-encoder.js +135 -0
  39. package/src/router-client/frames.js +329 -0
  40. package/src/router-client/head-merge.js +344 -0
  41. package/src/router-client/nav-error.js +105 -0
  42. package/src/router-client/navigator.js +781 -0
  43. package/src/router-client/prefetch.js +824 -0
  44. package/src/router-client/scroll.js +259 -0
  45. package/src/router-client/snapshot-cache.js +97 -0
  46. package/src/router-client/state.js +92 -0
  47. package/src/router-client/stream.js +206 -0
  48. package/src/router-client/swap.js +451 -0
  49. package/src/router-client/upgrade.js +261 -0
  50. package/src/router-client/view-transition.js +223 -0
  51. package/src/router-client.d.ts +10 -1
  52. package/src/router-client.js +75 -5345
  53. package/src/slot/assignment.js +304 -0
  54. package/src/slot/interception.js +558 -0
  55. package/src/slot/polyfills.js +365 -0
  56. package/src/slot/project.js +432 -0
  57. package/src/slot/sensors.js +303 -0
  58. package/src/slot/state.js +245 -0
  59. package/src/slot/symbols.js +85 -0
  60. package/src/slot.js +7 -2199
  61. package/src/webjs-config.d.ts +13 -2
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Client router: dom-parse.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ /**
11
+ * Parse a navigation response into a Document, PRESERVING COMMENTS.
12
+ *
13
+ * Comments are load-bearing here, not incidental: the partial swap pairs on
14
+ * `<!--wj:children:<path>-->` markers and hydration keys off `<!--webjs-hydrate-->`.
15
+ * `Document.parseHTMLUnsafe` would be the natural choice (it is the only
16
+ * single-pass API that also processes Declarative Shadow DOM) but it STRIPS
17
+ * EVERY COMMENT in Chromium 150 (#1007), which deletes both. So it is used only
18
+ * when a one-time probe proves it lossless on this engine, and otherwise we parse
19
+ * with DOMParser (comments preserved; DSD is left unprocessed, see
20
+ * `parseDocumentPreservingComments` for why that gap beats both ways of closing it).
21
+ *
22
+ * A partial-nav response (#936) is an INNER fragment that BEGINS with the
23
+ * `<!--wj:children:<segment>:<route-key>-->` boundary open and carries no
24
+ * `<!doctype>`/`<html>`. Parsing such a fragment as a DOCUMENT hoists that
25
+ * leading comment OUT of `<body>` (the HTML parser's "before html" insertion
26
+ * mode makes a leading comment a child of the document, before `<html>`), so
27
+ * `collectBoundaries(doc.body)` never sees the opening boundary, finds no
28
+ * shared segment, and `applySwap` degrades to a full load. So a fragment is
29
+ * parsed in BODY (fragment) context instead, keeping the boundary with its
30
+ * content.
31
+ * `body.setHTMLUnsafe` also processes Declarative Shadow DOM, so a shadow
32
+ * component inside the swapped content still re-attaches its root; the
33
+ * `<template>` path is the fallback for browsers without it (markers preserved,
34
+ * DSD not, which matches the pre-`setHTMLUnsafe` baseline).
35
+ *
36
+ * @param {string} html
37
+ * @returns {Document | null}
38
+ */
39
+ export function parseHTML(html) {
40
+ const isFragment = !/^\s*(?:<!doctype|<html)/i.test(html);
41
+ if (isFragment && typeof document !== 'undefined' && document.implementation) {
42
+ try {
43
+ const doc = document.implementation.createHTMLDocument();
44
+ if (typeof doc.body.setHTMLUnsafe === 'function') {
45
+ doc.body.setHTMLUnsafe(html);
46
+ } else {
47
+ const t = doc.createElement('template');
48
+ t.innerHTML = html;
49
+ doc.body.appendChild(t.content);
50
+ }
51
+ return doc;
52
+ } catch {
53
+ // Fall through to a document parse (still functional, just the #936 path).
54
+ }
55
+ }
56
+ if (
57
+ typeof Document !== 'undefined' &&
58
+ typeof Document.parseHTMLUnsafe === 'function' &&
59
+ parseHTMLUnsafePreservesComments()
60
+ ) {
61
+ return Document.parseHTMLUnsafe(html);
62
+ }
63
+ return parseDocumentPreservingComments(html);
64
+ }
65
+
66
+ /**
67
+ * Is `Document.parseHTMLUnsafe` lossless for comments?
68
+ *
69
+ * Chromium 150 strips EVERY comment from `Document.parseHTMLUnsafe` output
70
+ * (#1007). No other parse API does: `DOMParser`, `setHTMLUnsafe`,
71
+ * `template.innerHTML`, and plain `innerHTML` all preserve them, and the
72
+ * document's own navigation parser preserves them (which is why a hard refresh
73
+ * always looked fine and only soft nav broke). MDN documents parseHTMLUnsafe as
74
+ * the parse-WITHOUT-sanitization entry point, so this reads as a browser defect
75
+ * rather than intent, but the whole router rides on comments (`wj:children`
76
+ * layout markers) and so does hydration (`webjs-hydrate`), so we cannot take
77
+ * the risk either way.
78
+ *
79
+ * Probed once, lazily, rather than version-sniffed: when the browser is fixed
80
+ * we silently return to the fast single-pass native path, and a future browser
81
+ * that regresses the same way is caught with no code change.
82
+ *
83
+ * @returns {boolean}
84
+ */
85
+ let _parseUnsafeLossless = null;
86
+
87
+ function parseHTMLUnsafePreservesComments() {
88
+ if (_parseUnsafeLossless !== null) return _parseUnsafeLossless;
89
+ try {
90
+ const probe = Document.parseHTMLUnsafe('<!doctype html><body><!--c--><i></i>');
91
+ _parseUnsafeLossless = probe?.body?.firstChild?.nodeType === 8;
92
+ } catch {
93
+ _parseUnsafeLossless = false;
94
+ }
95
+ return _parseUnsafeLossless;
96
+ }
97
+
98
+ /**
99
+ * Clear the memoized losslessness probe. Test-only: a browser cannot change
100
+ * mid-session, so nothing in the runtime needs this. Tests SIMULATE a stripping
101
+ * parser (rather than depending on the runner's browser actually being an
102
+ * affected version, which the Chromium web-test-runner currently resolves is not) and reset the
103
+ * memo around that stub.
104
+ */
105
+ export function resetParseProbe() {
106
+ _parseUnsafeLossless = null;
107
+ }
108
+
109
+ /**
110
+ * Parse a FULL document while preserving comments.
111
+ *
112
+ * `DOMParser` keeps comments but does NOT process Declarative Shadow DOM, so a
113
+ * `<template shadowrootmode>` stays an inert template here. That is a DELIBERATE
114
+ * limitation on this path, and both obvious ways to "fix" it are worse than the
115
+ * gap (measured on Chromium 150, not reasoned about):
116
+ *
117
+ * - `body.setHTMLUnsafe(body.innerHTML)` re-serializes, and that round-trip is
118
+ * not idempotent: Chromium omits the spec's LF-compensation rule (append
119
+ * U+000A when a `pre` / `textarea` / `listing` element's first Text child
120
+ * starts with one), so `<textarea>\n\nfoo</textarea>` parses to `"\nfoo"`
121
+ * natively but `"foo"` after a round-trip. In a `<textarea>` that is silent
122
+ * form-data corruption: a soft nav would submit different bytes than a hard
123
+ * refresh.
124
+ * - Attaching each root by hand (`host.attachShadow()` + move the template's
125
+ * nodes) is USELESS on the common path and HARMFUL on the other. Useless
126
+ * because the marker swap imports with `document.importNode(n, true)`, which
127
+ * drops a shadow root unless it is `clonable`, and SSR emits a bare
128
+ * `<template shadowrootmode="open">`, so the root never survives the import
129
+ * and `component.js` re-attaches from scratch exactly as it always did.
130
+ * Harmful because the full-body-swap path ADOPTS instead, so a script-created
131
+ * root does survive, and a script-created root is not `declarative`: the spec
132
+ * only permits a second `attachShadow()` over an existing root when that root
133
+ * is declarative, so any element whose constructor unconditionally calls
134
+ * `attachShadow()` then throws `NotSupportedError` on upgrade, where the
135
+ * native parse it replaced worked fine.
136
+ *
137
+ * The gap this leaves is narrow and strictly better than the bug it replaces: on
138
+ * a comment-stripping browser only, an element that depends on DSD content and
139
+ * ships NO JavaScript loses that content on a full-body-swap navigation. Every
140
+ * WebJs `static shadow = true` component is unaffected, because it attaches and
141
+ * renders its own root on upgrade (`component.js`, guarded by `if
142
+ * (!this.shadowRoot)`), and a soft nav runs JS by definition. Tracked separately.
143
+ *
144
+ * @param {string} html
145
+ * @returns {Document | null}
146
+ */
147
+ function parseDocumentPreservingComments(html) {
148
+ if (typeof DOMParser === 'undefined') return null;
149
+ return new DOMParser().parseFromString(html, 'text/html');
150
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Client router: events.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+ import { findAnchorInPath } from './anchors.js';
10
+ import { NON_HTML_EXTENSIONS } from './constants.js';
11
+ import { enabled } from './state.js';
12
+ import { warnIfActionSubmissionCannotDeliver } from './diagnostics.js';
13
+ import { buildSubmitFormData, encodeSubmitBody, getSubmitAction, getSubmitEnctype, getSubmitMethod } from './form-encoder.js';
14
+ import { resolveTargetFrameId } from './frames.js';
15
+ import { performNavigation, performSubmission } from './navigator.js';
16
+
17
+ /** @param {MouseEvent} e */
18
+ export function onClick(e) {
19
+ if (!enabled) return;
20
+ if (e.defaultPrevented || e.button !== 0) return;
21
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
22
+
23
+ const anchor = findAnchorInPath(e);
24
+ if (!anchor) return;
25
+ if (anchor.hasAttribute('download')) return;
26
+ if (anchor.hasAttribute('data-no-router')) return;
27
+ if (anchor.target && anchor.target !== '_self') return;
28
+
29
+ const href = anchor.href;
30
+ if (!href) return;
31
+
32
+ const url = new URL(href);
33
+ if (url.origin !== location.origin) return;
34
+ if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
35
+ if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
36
+
37
+ e.preventDefault();
38
+ // Resolve the target frame. An explicit `data-webjs-frame` on (or above)
39
+ // the anchor drives a frame by id from anywhere in the document (an
40
+ // external sidebar/nav link), `_top` breaks out to a full-page nav, and
41
+ // absence falls back to the closest enclosing frame (today's default).
42
+ const frameId = resolveTargetFrameId(anchor);
43
+ performNavigation(href, false, frameId);
44
+ }
45
+
46
+ /** @param {PopStateEvent} _e */
47
+ export function onPopState(_e) {
48
+ // popstate has no DOM anchor, so no frame context: restore via cache or
49
+ // refetch the whole document.
50
+ performNavigation(location.href, true, null);
51
+ }
52
+
53
+ /**
54
+ * Intercept form submissions. BUBBLE phase (see enableClientRouter) so we run
55
+ * AFTER a component's per-element `@submit` handler, which is bound at-target.
56
+ * That ordering is what makes the `if (e.defaultPrevented) return` guard below
57
+ * work: a component that calls `e.preventDefault()` (the chat / comments forms,
58
+ * or any JS-handled form) has already run, so we see the prevented default and
59
+ * leave the form alone. A capture listener would fire us first, before the
60
+ * component, defeating the guard and wrongly navigating the page out from under
61
+ * a JS-handled form.
62
+ *
63
+ * Filtering mirrors Turbo's `form_submit_observer.js`:
64
+ * - `data-no-router` on form or submitter → full browser submit.
65
+ * - `formmethod="dialog"` → native <dialog> dismissal, never routed.
66
+ * - `target` / `formtarget` that isn't `_self` → iframe / popup target.
67
+ * - Cross-origin or non-HTML-extension action → let the browser handle.
68
+ *
69
+ * Submitter attributes (`formmethod`, `formaction`, `formenctype`) take
70
+ * precedence over the form's own: HTML5 form-submission algorithm.
71
+ *
72
+ * @param {SubmitEvent} e
73
+ */
74
+ export function onSubmit(e) {
75
+ if (!enabled) return;
76
+ if (e.defaultPrevented) return;
77
+
78
+ const form = /** @type {HTMLFormElement | null} */ (e.target);
79
+ // Duck-type check rather than `instanceof HTMLFormElement`: linkedom
80
+ // and other non-browser DOMs don't always mark form elements as
81
+ // instances of the window's HTMLFormElement class.
82
+ if (!form || form.nodeType !== 1 || form.tagName !== 'FORM') return;
83
+ if (form.hasAttribute('data-no-router')) return;
84
+
85
+ const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
86
+ if (submitter && submitter.hasAttribute('data-no-router')) return;
87
+
88
+ // Presence, not truthiness, exactly as `getSubmitAction` resolves the action
89
+ // (#1322). The form-submission algorithm asks whether the submitter HAS a
90
+ // `formtarget`, so a present-but-empty one overrides the form and then means
91
+ // the current browsing context, per the rules for choosing a navigable.
92
+ const target = (submitter && submitter.hasAttribute('formtarget'))
93
+ ? (submitter.getAttribute('formtarget') || '')
94
+ : (form.getAttribute('target') || '');
95
+ if (target && target !== '_self') return;
96
+
97
+ const method = getSubmitMethod(form, submitter);
98
+ if (method === 'dialog') return;
99
+
100
+ const enctype = getSubmitEnctype(form, submitter);
101
+ const isSafeMethod = method === 'get' || method === 'head';
102
+
103
+ const action = getSubmitAction(form, submitter);
104
+ /** @type {URL} */ let url;
105
+ try { url = new URL(action, location.href); }
106
+ catch { return; }
107
+ if (url.origin !== location.origin) return;
108
+ if (NON_HTML_EXTENSIONS.test(url.pathname)) return;
109
+
110
+ // Built once, after the cheap bails (a submission the router ignores should
111
+ // not pay for a FormData) and BEFORE the text/plain bail below. That order
112
+ // matters for the dev report: `text/plain` is precisely the case where the
113
+ // router declines the submission, so reporting after the bail would be dead
114
+ // code for the one shape that most needs it, since both paths are then
115
+ // answered with a 405 and the author gets no other signal.
116
+ const rawBody = buildSubmitFormData(form, submitter);
117
+ // Observational, and silent in production. Runs before `preventDefault`.
118
+ warnIfActionSubmissionCannotDeliver(form, submitter, method, rawBody);
119
+
120
+ // #1307: `text/plain` is a legal native encoding the server cannot parse
121
+ // (`looksLikeFormSubmission` accepts multipart and urlencoded only), and
122
+ // there is no honest way to send it over `fetch` and have the response mean
123
+ // anything. Bail to the browser so BOTH paths do the same thing, rather than
124
+ // silently sending multipart, which is what made the same form behave one
125
+ // way with JS and another way without it. Turbo enumerates this encoding and
126
+ // then sends FormData anyway, which is the divergence being avoided here. A
127
+ // safe method ignores the enctype entirely, per the submission algorithm.
128
+ if (!isSafeMethod && enctype === 'text/plain') return;
129
+
130
+ const body = encodeSubmitBody(rawBody, enctype);
131
+
132
+ e.preventDefault();
133
+ // Resolve the target frame for the submit, same precedence as a link:
134
+ // an explicit `data-webjs-frame` on (or above) the form or its submitter
135
+ // wins, `_top` breaks out, absence falls back to the enclosing frame.
136
+ const frameId = resolveTargetFrameId(submitter || form);
137
+ performSubmission(url.href, method, body, frameId, form);
138
+ }
139
+