@nifrajs/web 2.1.0 → 2.3.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/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +62 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +103 -49
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/web/bun-dev-entry` - discovering the client-entry URL Bun's dev server assigns.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists at all
|
|
5
|
+
*
|
|
6
|
+
* Bun's dev server bundles an HTML route and rewrites its `<script src="./entry.tsx">` into a
|
|
7
|
+
* content-hashed URL under `/_bun/client/`. That rewrite is the whole point: the hash changes per build,
|
|
8
|
+
* so the URL cannot be predicted or hard-coded.
|
|
9
|
+
*
|
|
10
|
+
* nifra needs that URL, because nifra renders the document. Bun never sees the page it would inject the
|
|
11
|
+
* script into - `createWebApp` builds the HTML from the route's own output and needs `clientEntry` to
|
|
12
|
+
* emit `<script type="module" src=…>` and the matching `modulepreload`. So the two halves have to meet:
|
|
13
|
+
* Bun knows the URL, nifra needs it, and there is no supported API connecting them. Verified against Bun
|
|
14
|
+
* 1.3.14: the HTML import evaluates to `{}` with no keys, the `Bun.serve` instance exposes nothing
|
|
15
|
+
* asset-related, and `/_bun/manifest`, `/_bun/client/manifest.json` and `/_bun/assets` all 404.
|
|
16
|
+
*
|
|
17
|
+
* The only route is to ask the dev server for a page it *did* bundle and read the URL back out of it.
|
|
18
|
+
*
|
|
19
|
+
* ## Why it is a module instead of four lines inline
|
|
20
|
+
*
|
|
21
|
+
* A regex over another tool's output is a dependency on an unspecified format, and that is true whether
|
|
22
|
+
* it lives here or inline in the dev server. What changes is the blast radius. Isolated here it is one
|
|
23
|
+
* function with one caller, covered by tests that pin the exact tag shape Bun emits today - so a Bun
|
|
24
|
+
* upgrade that changes the format fails a test with a diff of the markup instead of producing a dev
|
|
25
|
+
* server that boots fine and serves pages whose scripts 404. And if Bun ever exposes a real accessor,
|
|
26
|
+
* this is the only body that changes.
|
|
27
|
+
*
|
|
28
|
+
* Detection is layered so a cosmetic change degrades instead of breaking: the `data-bun-dev-server-script`
|
|
29
|
+
* attribute is the precise signal, an unambiguous single module script under `/_bun/` is the fallback,
|
|
30
|
+
* and anything else is a named error that says what was searched for and what was found.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** One `<script>` tag lifted out of a served page: its raw attribute text and parsed `src`. */
|
|
34
|
+
interface ScriptTag {
|
|
35
|
+
readonly attrs: string
|
|
36
|
+
readonly src: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `<script …>` open tags. Attribute text is captured raw; `src` is parsed out of it separately so the
|
|
40
|
+
// two are independent of attribute ORDER (Bun emits `crossorigin src=… data-bun-dev-server-script`, but
|
|
41
|
+
// nothing in HTML makes that order stable).
|
|
42
|
+
const SCRIPT_OPEN = /<script\b([^>]*)>/gi
|
|
43
|
+
const SRC_ATTR = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
44
|
+
// The attribute Bun marks its own bundled entry with. Valueless today; matched tolerantly so a future
|
|
45
|
+
// `data-bun-dev-server-script="1"` still hits.
|
|
46
|
+
const MARKER_ATTR = /\bdata-bun-dev-server-script\b/i
|
|
47
|
+
// Bun serves every bundled client asset under this prefix.
|
|
48
|
+
const BUN_CLIENT_PREFIX = "/_bun/"
|
|
49
|
+
|
|
50
|
+
/** Pull every `<script>` open tag out of an HTML document, in source order. */
|
|
51
|
+
function scriptTags(html: string): ScriptTag[] {
|
|
52
|
+
const tags: ScriptTag[] = []
|
|
53
|
+
SCRIPT_OPEN.lastIndex = 0
|
|
54
|
+
for (let m = SCRIPT_OPEN.exec(html); m !== null; m = SCRIPT_OPEN.exec(html)) {
|
|
55
|
+
const attrs = m[1] ?? ""
|
|
56
|
+
const src = SRC_ATTR.exec(attrs)
|
|
57
|
+
tags.push({ attrs, src: src?.[1] ?? src?.[2] ?? src?.[3] })
|
|
58
|
+
}
|
|
59
|
+
return tags
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** How the entry URL was found - reported so a fallback hit is visible rather than silent. */
|
|
63
|
+
export type DevEntryVia = "marker" | "single-bun-script"
|
|
64
|
+
|
|
65
|
+
/** A located client entry: the URL to hand nifra, its stylesheets, and which signal found it. */
|
|
66
|
+
export interface DevEntryMatch {
|
|
67
|
+
readonly src: string
|
|
68
|
+
/**
|
|
69
|
+
* Stylesheet URLs Bun extracted from the entry's CSS imports.
|
|
70
|
+
*
|
|
71
|
+
* These are not a nicety. Bun's bundler pulls `import "./app.css"` out of the JS graph and links it
|
|
72
|
+
* from the HTML *it* bundled - the throwaway probe page, which nobody ever sees. nifra renders the real
|
|
73
|
+
* pages, so unless these are carried across, a Bun-pipeline dev session serves every page unstyled
|
|
74
|
+
* while the production build (which reads CSS from the build manifest) is fine. That is the worst
|
|
75
|
+
* possible shape for a bug: dev-only, silent, and looks like the app's own CSS is broken.
|
|
76
|
+
*/
|
|
77
|
+
readonly styles: readonly string[]
|
|
78
|
+
readonly via: DevEntryVia
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `<link rel="stylesheet" href="…">`. `rel` and `href` are matched independently of order, same reason
|
|
82
|
+
// as the script tags: attribute order in generated markup is not a contract.
|
|
83
|
+
const LINK_OPEN = /<link\b([^>]*)>/gi
|
|
84
|
+
const HREF_ATTR = /\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i
|
|
85
|
+
const REL_STYLESHEET =
|
|
86
|
+
/\brel\s*=\s*(?:"[^"]*\bstylesheet\b[^"]*"|'[^']*\bstylesheet\b[^']*'|stylesheet)/i
|
|
87
|
+
|
|
88
|
+
/** Every stylesheet URL linked from a document, in source order. */
|
|
89
|
+
export function parseDevStyles(html: string): string[] {
|
|
90
|
+
const hrefs: string[] = []
|
|
91
|
+
LINK_OPEN.lastIndex = 0
|
|
92
|
+
for (let m = LINK_OPEN.exec(html); m !== null; m = LINK_OPEN.exec(html)) {
|
|
93
|
+
const attrs = m[1] ?? ""
|
|
94
|
+
if (!REL_STYLESHEET.test(attrs)) continue
|
|
95
|
+
const href = HREF_ATTR.exec(attrs)
|
|
96
|
+
const url = href?.[1] ?? href?.[2] ?? href?.[3]
|
|
97
|
+
if (url !== undefined && url !== "") hrefs.push(url)
|
|
98
|
+
}
|
|
99
|
+
return hrefs
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find the bundled client-entry URL in a page Bun's dev server rendered. Pure - the whole format
|
|
104
|
+
* dependency lives in this function, which is what makes it testable against captured real output.
|
|
105
|
+
*
|
|
106
|
+
* Returns `undefined` rather than throwing so the caller can attach the context a useful error needs
|
|
107
|
+
* (which URL was probed, what the server returned).
|
|
108
|
+
*/
|
|
109
|
+
export function parseDevEntry(html: string): DevEntryMatch | undefined {
|
|
110
|
+
const tags = scriptTags(html)
|
|
111
|
+
const styles = parseDevStyles(html)
|
|
112
|
+
// 1. The precise signal: Bun's own marker attribute.
|
|
113
|
+
for (const tag of tags) {
|
|
114
|
+
if (MARKER_ATTR.test(tag.attrs) && tag.src !== undefined && tag.src !== "") {
|
|
115
|
+
return { src: tag.src, styles, via: "marker" }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 2. Fallback: exactly one script pointing into Bun's client-asset prefix. Requiring uniqueness is what
|
|
119
|
+
// keeps this a fallback and not a guess - with two candidates there is no basis for picking one, so
|
|
120
|
+
// it declines and lets the caller raise a real error.
|
|
121
|
+
const bunScripts = tags.filter((t) => t.src?.startsWith(BUN_CLIENT_PREFIX))
|
|
122
|
+
const only = bunScripts.length === 1 ? bunScripts[0] : undefined
|
|
123
|
+
if (only?.src !== undefined) return { src: only.src, styles, via: "single-bun-script" }
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The error thrown when Bun's dev server serves a page nothing in it can be recognised as the entry. */
|
|
128
|
+
export function devEntryNotFoundMessage(probeUrl: string, html: string): string {
|
|
129
|
+
const scripts = scriptTags(html)
|
|
130
|
+
const found =
|
|
131
|
+
scripts.length === 0
|
|
132
|
+
? "the page contains no <script> tags at all"
|
|
133
|
+
: `the page's script tags are: ${scripts.map((s) => `<script${s.attrs}>`).join(", ")}`
|
|
134
|
+
return (
|
|
135
|
+
`[nifra] could not find Bun's bundled client entry in the page served at ${probeUrl}.\n` +
|
|
136
|
+
` Looked for a <script> carrying \`data-bun-dev-server-script\`, then for a single module script ` +
|
|
137
|
+
`under \`${BUN_CLIENT_PREFIX}\`; ${found}.\n` +
|
|
138
|
+
` This is how Bun's dev server labelled its bundled entry when this was written, so a Bun upgrade ` +
|
|
139
|
+
`that changes the markup lands here first. Run the Bun pipeline dev server's tests to see the ` +
|
|
140
|
+
`current shape, or use the Vite pipeline (\`nifra dev\`) meanwhile.`
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Structural slice of a running server this needs: just the port it bound. */
|
|
145
|
+
export interface ServedOn {
|
|
146
|
+
readonly port: number
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ResolveDevEntryOptions {
|
|
150
|
+
/** Path of the throwaway route serving Bun's bundled HTML (e.g. `/__nifra/entry`). */
|
|
151
|
+
readonly probePath: string
|
|
152
|
+
/** Injected for tests; defaults to global `fetch`. */
|
|
153
|
+
readonly fetchImpl?: typeof fetch
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ask Bun's dev server for the current bundled entry URL. One loopback request against a route the app
|
|
158
|
+
* never exposes.
|
|
159
|
+
*
|
|
160
|
+
* **The answer expires.** The URL is a content hash over the whole client graph, so editing any file it
|
|
161
|
+
* reaches - a route, a component, a stylesheet - re-hashes it. Caching the startup value and injecting it
|
|
162
|
+
* forever produces a spectacular failure: Bun answers a superseded chunk URL with a 104-byte
|
|
163
|
+
* `location.reload()` stub under a 405, so the page reloads, gets the same dead URL from SSR, and reloads
|
|
164
|
+
* again, forever. No console output survives the loop to explain it. Callers must re-resolve rather than
|
|
165
|
+
* remember - see how `@nifrajs/web/dev` serves a stable URL that redirects here per request.
|
|
166
|
+
*/
|
|
167
|
+
export async function resolveDevEntry(
|
|
168
|
+
server: ServedOn,
|
|
169
|
+
options: ResolveDevEntryOptions,
|
|
170
|
+
): Promise<DevEntryMatch> {
|
|
171
|
+
const doFetch = options.fetchImpl ?? fetch
|
|
172
|
+
const probeUrl = `http://127.0.0.1:${server.port}${options.probePath}`
|
|
173
|
+
const response = await doFetch(probeUrl)
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`[nifra] Bun's dev server returned ${response.status} for the client-entry probe at ${probeUrl}. ` +
|
|
177
|
+
"The generated HTML route did not build - the error above this line is Bun's.",
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
const html = await response.text()
|
|
181
|
+
const match = parseDevEntry(html)
|
|
182
|
+
if (match === undefined) throw new Error(devEntryNotFoundMessage(probeUrl, html))
|
|
183
|
+
return match
|
|
184
|
+
}
|
package/src/chunk-names.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* URL-safe output chunk naming. Bun names a code-split chunk after its source file's basename, so a
|
|
3
|
-
* dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js`
|
|
3
|
+
* dynamic-route file like `[slug].tsx` emits `[slug]-<hash>.js` - the `[ ]` make a URL a static server
|
|
4
4
|
* (server-bun, CF Pages) reject with 400, so the lazy import fails and the route silently never
|
|
5
5
|
* hydrates (SSR looks perfect; all interactivity is dead). `sanitizeOutputNames` renames every output
|
|
6
6
|
* whose basename isn't URL-path-safe (`[slug]` → `_slug_`) and rewrites the references to it inside the
|
package/src/client.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/web/client`
|
|
2
|
+
* `@nifrajs/web/client` - the agnostic browser layer for client-side navigation. It wires the pure
|
|
3
3
|
* router store ({@link ClientRouter}) to the browser: `pushState` on navigate, `popstate` →
|
|
4
4
|
* navigate, and delegated interception of same-origin `<a>` clicks (client transition instead of
|
|
5
|
-
* a full page load). DOM-only
|
|
5
|
+
* a full page load). DOM-only - never imported on the server, so the store stays SSR-safe.
|
|
6
6
|
*/
|
|
7
|
+
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
8
|
+
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
7
9
|
import type { Meta } from "./manifest.ts"
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type Blocker,
|
|
12
|
+
type BlockerController,
|
|
13
|
+
type BlockerFunction,
|
|
14
|
+
type BlockerLocation,
|
|
15
|
+
type BlockerState,
|
|
16
|
+
type BrowserNavigate,
|
|
17
|
+
IDLE_BLOCKER,
|
|
18
|
+
setBlockerController,
|
|
19
|
+
setBrowserNavigate,
|
|
20
|
+
} from "./navigation.ts"
|
|
9
21
|
import type { ClientRouter } from "./router.ts"
|
|
10
22
|
|
|
11
23
|
export interface InstallHistoryOptions {
|
|
@@ -31,6 +43,22 @@ export function installHistory(
|
|
|
31
43
|
// fragment target (`#id`) to scroll to once the cross-page navigation's content renders.
|
|
32
44
|
type PendingScroll = { readonly pos: readonly [number, number] } | { readonly hash: string }
|
|
33
45
|
let pendingScroll: PendingScroll | null = null
|
|
46
|
+
|
|
47
|
+
// Navigation-guard state (useBlocker). `index` is our position in the history line, stored in
|
|
48
|
+
// `history.state.nifraIndex`; it exists solely to reverse a blocked back/forward (a popstate can't be
|
|
49
|
+
// cancelled, so we read the delta from the index and `history.go(-delta)` back). `here` is the location
|
|
50
|
+
// we're currently on - after a popstate `location` is already the destination, so "where we were" has
|
|
51
|
+
// to be tracked, not read. `reversing` swallows the popstate our own restoring `history.go` fires.
|
|
52
|
+
let index = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
53
|
+
let here = location.pathname + location.search + (location.hash ?? "")
|
|
54
|
+
let reversing = false
|
|
55
|
+
type Registration = {
|
|
56
|
+
readonly shouldBlock: BlockerFunction
|
|
57
|
+
readonly emit: (blocker: Blocker) => void
|
|
58
|
+
state: BlockerState
|
|
59
|
+
}
|
|
60
|
+
// A single active guard (like react-router, one blocker at a time); the latest registration wins.
|
|
61
|
+
let registration: Registration | undefined
|
|
34
62
|
const scrollOf = (state: unknown): [number, number] => {
|
|
35
63
|
const saved = (state as { nifraScroll?: [number, number] } | null)?.nifraScroll
|
|
36
64
|
return Array.isArray(saved) ? saved : [0, 0]
|
|
@@ -50,7 +78,7 @@ export function installHistory(
|
|
|
50
78
|
return document.getElementById(id)
|
|
51
79
|
}
|
|
52
80
|
|
|
53
|
-
// Wrap a navigation's render in a View Transition when the browser supports it
|
|
81
|
+
// Wrap a navigation's render in a View Transition when the browser supports it - a graceful
|
|
54
82
|
// enhancement (no-op elsewhere). The captured "before" is held across the data fetch, so pair
|
|
55
83
|
// with link prefetch (hover/focus warms the cache) to keep most transitions instant.
|
|
56
84
|
type ViewTransition = {
|
|
@@ -69,35 +97,112 @@ export function installHistory(
|
|
|
69
97
|
}
|
|
70
98
|
const vt = doc.startViewTransition(run)
|
|
71
99
|
// Rapid in-app clicks supersede an in-flight transition; the browser skips it and rejects its
|
|
72
|
-
// promises. Reasons vary by engine
|
|
100
|
+
// promises. Reasons vary by engine - InvalidStateError "Transition was aborted because of
|
|
73
101
|
// invalid state", or AbortError "Transition was skipped". The navigation still completes, so
|
|
74
102
|
// we swallow these expected aborts rather than let them surface as unhandled rejections.
|
|
75
103
|
for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
|
|
76
104
|
}
|
|
77
105
|
|
|
78
|
-
|
|
106
|
+
// Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
|
|
107
|
+
const locationOf = (path: string): BlockerLocation => {
|
|
79
108
|
const url = new URL(path, location.origin)
|
|
109
|
+
return { pathname: url.pathname, search: url.search, hash: url.hash }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The single funnel every navigation asks before it commits. Returns true (navigation held) when a
|
|
113
|
+
// guard is armed and blocks; false when it may proceed. `retry` performs the navigation and is what
|
|
114
|
+
// `proceed` replays. While a prompt is already open (`blocked`) further navigations are swallowed; the
|
|
115
|
+
// replay from `proceed` (`proceeding`) is let straight through.
|
|
116
|
+
const guard = (nextPath: string, retry: () => void): boolean => {
|
|
117
|
+
const reg = registration
|
|
118
|
+
if (reg === undefined || reg.state === "proceeding") return false
|
|
119
|
+
if (reg.state === "blocked") return true
|
|
120
|
+
if (!reg.shouldBlock({ currentLocation: locationOf(here), nextLocation: locationOf(nextPath) }))
|
|
121
|
+
return false
|
|
122
|
+
reg.state = "blocked"
|
|
123
|
+
reg.emit({
|
|
124
|
+
state: "blocked",
|
|
125
|
+
proceed: () => {
|
|
126
|
+
if (reg.state !== "blocked") return
|
|
127
|
+
reg.state = "proceeding"
|
|
128
|
+
reg.emit({ state: "proceeding", proceed: undefined, reset: undefined })
|
|
129
|
+
retry()
|
|
130
|
+
},
|
|
131
|
+
reset: () => {
|
|
132
|
+
if (reg.state !== "blocked") return
|
|
133
|
+
reg.state = "unblocked"
|
|
134
|
+
reg.emit(IDLE_BLOCKER)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Once a proceeded navigation has committed, return the guard to idle so it can block the next one.
|
|
141
|
+
const settle = (): void => {
|
|
142
|
+
const reg = registration
|
|
143
|
+
if (reg !== undefined && reg.state === "proceeding") {
|
|
144
|
+
reg.state = "unblocked"
|
|
145
|
+
reg.emit(IDLE_BLOCKER)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Commit a navigation only after its URL and origin are known-safe. An unmatched same-origin path
|
|
150
|
+
// becomes a hard load BEFORE history is mutated; otherwise the address bar could change while the
|
|
151
|
+
// router intentionally kept rendering the old route.
|
|
152
|
+
const parseNavigationUrl = (path: string): URL | null => {
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(path, location.origin)
|
|
155
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null
|
|
156
|
+
if (url.origin !== location.origin) return null
|
|
157
|
+
return url
|
|
158
|
+
} catch {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const commit = (path: string, url: URL, mode: "push" | "replace"): void => {
|
|
164
|
+
const routePath = url.pathname + url.search
|
|
165
|
+
if (router.match(routePath) === null) {
|
|
166
|
+
settle()
|
|
167
|
+
fallback(path)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
80
170
|
if (mode === "replace") {
|
|
81
|
-
// Replace the current entry with the new URL. The entry we're leaving is discarded, so
|
|
82
|
-
// isn't worth saving; the new route scrolls to its fragment target, else the top.
|
|
83
|
-
history.replaceState({}, "", path)
|
|
171
|
+
// Replace the current entry (same index) with the new URL. The entry we're leaving is discarded, so
|
|
172
|
+
// its scroll isn't worth saving; the new route scrolls to its fragment target, else the top.
|
|
173
|
+
history.replaceState({ nifraIndex: index }, "", path)
|
|
84
174
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
85
175
|
} else {
|
|
86
|
-
// Save the leaving entry's scroll
|
|
87
|
-
// route: to the fragment target if
|
|
176
|
+
// Save the leaving entry's scroll (spread keeps its nifraIndex), push a fresh higher-indexed entry
|
|
177
|
+
// (URL incl. any #hash), then scroll the new route: to the fragment target if given, else the top.
|
|
88
178
|
history.replaceState({ ...(history.state ?? {}), nifraScroll: [scrollX, scrollY] }, "")
|
|
89
|
-
|
|
179
|
+
index += 1
|
|
180
|
+
history.pushState({ nifraIndex: index }, "", path)
|
|
90
181
|
pendingScroll = url.hash !== "" ? { hash: hashId(url.hash) } : { pos: [0, 0] }
|
|
91
182
|
}
|
|
183
|
+
here = url.pathname + url.search + url.hash
|
|
92
184
|
// The data layer fetches by path+search; the #hash is client-only (never sent to the server).
|
|
93
|
-
transition(() => router.navigate(
|
|
185
|
+
transition(() => router.navigate(routePath).catch(() => fallback(path)))
|
|
186
|
+
settle()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const go = (path: string, mode: "push" | "replace"): void => {
|
|
190
|
+
// Validate before the blocker observes the target. A blocker receives parsed locations, so letting a
|
|
191
|
+
// malformed string reach `locationOf` would make an otherwise no-throw navigate unexpectedly throw.
|
|
192
|
+
const url = parseNavigationUrl(path)
|
|
193
|
+
if (url === null) {
|
|
194
|
+
settle()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (guard(path, () => commit(path, url, mode))) return
|
|
198
|
+
commit(path, url, mode)
|
|
94
199
|
}
|
|
95
200
|
|
|
96
201
|
// Programmatic navigation for adapter `useNavigate` bindings, published through the DOM-free bridge
|
|
97
202
|
// (`@nifrajs/web`'s `getBrowserNavigate`). A string path pushes (or replaces); a number is a history
|
|
98
|
-
// delta (`-1` back / `1` forward), matching `history.go`.
|
|
99
|
-
//
|
|
100
|
-
//
|
|
203
|
+
// delta (`-1` back / `1` forward), matching `history.go`. Off-route same-origin destinations hard-load
|
|
204
|
+
// without first creating a stale in-document history entry. Cross-origin and active-scheme strings are
|
|
205
|
+
// rejected; callers that intentionally leave the app use a normal `<a>`.
|
|
101
206
|
const navigate: BrowserNavigate = (to, navOptions) => {
|
|
102
207
|
if (typeof to === "number") {
|
|
103
208
|
history.go(to)
|
|
@@ -107,6 +212,20 @@ export function installHistory(
|
|
|
107
212
|
}
|
|
108
213
|
setBrowserNavigate(navigate)
|
|
109
214
|
|
|
215
|
+
// Publish the guard registry through the DOM-free bridge (`@nifrajs/web`'s `registerBlocker`, which an
|
|
216
|
+
// adapter's `useBlocker` calls). One slot, latest registration wins; the unregister clears it only if
|
|
217
|
+
// it's still the current one (so a stale unmount doesn't wipe a newer guard).
|
|
218
|
+
const controller: BlockerController = {
|
|
219
|
+
register(shouldBlock, onChange) {
|
|
220
|
+
const reg: Registration = { shouldBlock, emit: onChange, state: "unblocked" }
|
|
221
|
+
registration = reg
|
|
222
|
+
return () => {
|
|
223
|
+
if (registration === reg) registration = undefined
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
setBlockerController(controller)
|
|
228
|
+
|
|
110
229
|
// Resolve an event target to an in-app route path, or null (cross-origin, unknown route, or an
|
|
111
230
|
// anchor opting out via target/download/rel=external). Shared by click + hover/focus prefetch.
|
|
112
231
|
const inAppHref = (target: EventTarget | null): string | null => {
|
|
@@ -118,9 +237,9 @@ export function installHistory(
|
|
|
118
237
|
const url = new URL(anchor.href)
|
|
119
238
|
if (url.origin !== location.origin) return null
|
|
120
239
|
if (router.match(url.pathname) === null) return null
|
|
121
|
-
// A same-page fragment link (`#section`, `/here#section`
|
|
240
|
+
// A same-page fragment link (`#section`, `/here#section` - only the hash differs) → null: let the
|
|
122
241
|
// browser do its native in-page anchor jump. Intercepting it would drop the fragment from the URL
|
|
123
|
-
// and force a scroll-to-top (a fresh push restores [0,0])
|
|
242
|
+
// and force a scroll-to-top (a fresh push restores [0,0]) - i.e. break every in-page anchor (AUDIT
|
|
124
243
|
// H2). (A same-page link with NO hash still soft-navigates, as before.)
|
|
125
244
|
if (url.pathname === location.pathname && url.search === location.search && url.hash !== "")
|
|
126
245
|
return null
|
|
@@ -146,12 +265,49 @@ export function installHistory(
|
|
|
146
265
|
void router.prefetch(url.pathname + url.search) // warm by path+search; the #hash isn't data
|
|
147
266
|
}
|
|
148
267
|
|
|
149
|
-
// Back/forward: the entry exists (no push)
|
|
268
|
+
// Back/forward: the entry already exists (no push). The URL has ALSO already changed - a popstate can't
|
|
269
|
+
// be cancelled - so a guard here can only restore-then-prompt: read the delta from our index, and if the
|
|
270
|
+
// guard holds, `history.go(-delta)` back to where we were (ignoring the popstate that reversal fires via
|
|
271
|
+
// `reversing`) while `proceed` re-issues `history.go(delta)` to redo the move. Unguarded, it's the old
|
|
272
|
+
// path: restore the entry's saved scroll after it renders.
|
|
150
273
|
const onPopState = (): void => {
|
|
274
|
+
if (reversing) {
|
|
275
|
+
reversing = false
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
const newIndex = (history.state as { nifraIndex?: number } | null)?.nifraIndex ?? 0
|
|
279
|
+
const dest = location.pathname + location.search + (location.hash ?? "")
|
|
280
|
+
const delta = newIndex - index
|
|
281
|
+
if (guard(dest, () => history.go(delta))) {
|
|
282
|
+
reversing = true
|
|
283
|
+
history.go(-delta)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
index = newIndex
|
|
287
|
+
here = dest
|
|
151
288
|
pendingScroll = { pos: scrollOf(history.state) }
|
|
152
289
|
transition(() =>
|
|
153
290
|
router.navigate(location.pathname + location.search).catch(() => fallback(location.pathname)),
|
|
154
291
|
)
|
|
292
|
+
settle()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Close / reload / hard navigation away: no in-app destination exists, so consult the guard with the
|
|
296
|
+
// current location as both sides. A dirtiness guard (a boolean, or one that checks its own state) then
|
|
297
|
+
// triggers the browser's native "Leave site?" prompt; a purely destination-based guard won't. Browsers
|
|
298
|
+
// ignore any custom message - the string is set only because legacy engines require `returnValue`.
|
|
299
|
+
const onBeforeUnload = (event: BeforeUnloadEvent): void => {
|
|
300
|
+
const reg = registration
|
|
301
|
+
if (reg === undefined || reg.state === "proceeding") return
|
|
302
|
+
// `blocked` already proves the guard found unsaved state. Re-check only while idle; suppressing the
|
|
303
|
+
// native prompt merely because an in-app confirmation is open creates a close/reload data-loss gap.
|
|
304
|
+
if (
|
|
305
|
+
reg.state === "unblocked" &&
|
|
306
|
+
!reg.shouldBlock({ currentLocation: locationOf(here), nextLocation: locationOf(here) })
|
|
307
|
+
)
|
|
308
|
+
return
|
|
309
|
+
event.preventDefault()
|
|
310
|
+
event.returnValue = ""
|
|
155
311
|
}
|
|
156
312
|
|
|
157
313
|
// After a navigation settles (content rendered), apply the pending scroll target on the next frame:
|
|
@@ -177,37 +333,49 @@ export function installHistory(
|
|
|
177
333
|
document.addEventListener("pointerover", onPrefetch)
|
|
178
334
|
document.addEventListener("focusin", onPrefetch)
|
|
179
335
|
window.addEventListener("popstate", onPopState)
|
|
336
|
+
window.addEventListener("beforeunload", onBeforeUnload)
|
|
180
337
|
return () => {
|
|
181
338
|
unsubscribe()
|
|
182
339
|
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
340
|
+
setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
|
|
183
341
|
document.removeEventListener("click", onClick)
|
|
184
342
|
document.removeEventListener("pointerover", onPrefetch)
|
|
185
343
|
document.removeEventListener("focusin", onPrefetch)
|
|
186
344
|
window.removeEventListener("popstate", onPopState)
|
|
345
|
+
window.removeEventListener("beforeunload", onBeforeUnload)
|
|
187
346
|
}
|
|
188
347
|
}
|
|
189
348
|
|
|
190
349
|
/**
|
|
191
350
|
* Intercept submissions of same-origin `<form method="post">` whose action targets an app route:
|
|
192
|
-
* submit via the router (no full reload
|
|
351
|
+
* submit via the router (no full reload - POST the action, then revalidate the active loader). On
|
|
193
352
|
* failure it falls back to a native submit, so the form still works. Returns a teardown function.
|
|
194
353
|
*/
|
|
195
354
|
/**
|
|
196
355
|
* Sync the document head to a route's resolved {@link Meta} on client navigation. Sets the title
|
|
197
|
-
* (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags
|
|
356
|
+
* (when provided) and replaces the **managed** (`data-nifra`) `<meta>`/`<link>` tags - static head
|
|
198
357
|
* content (charset, hand-written tags) is never touched. SSR injects the same `data-nifra` tags, so
|
|
199
358
|
* the first navigation cleanly takes over from the server-rendered head.
|
|
200
359
|
*/
|
|
201
360
|
export function applyHead(head: Meta): void {
|
|
202
361
|
if (head.title !== undefined) document.title = head.title
|
|
362
|
+
// `<html lang>`/`<html dir>` - applied AUTHORITATIVELY (unlike `title`, which is left alone when the
|
|
363
|
+
// route omits it), mirroring the SSR shell's defaulting exactly: `lang` falls back to `"en"`, `dir` is
|
|
364
|
+
// removed when unset. Anything laxer drifts on a multilingual site - navigating /ur → /en would leave
|
|
365
|
+
// `dir="rtl"` behind and lay the English page out right-to-left, a bug a hard reload would not show.
|
|
366
|
+
const root = document.documentElement
|
|
367
|
+
root.setAttribute("lang", head.lang ?? "en")
|
|
368
|
+
if (head.dir === undefined) root.removeAttribute("dir")
|
|
369
|
+
else root.setAttribute("dir", head.dir)
|
|
203
370
|
for (const el of document.head.querySelectorAll("[data-nifra]")) el.remove()
|
|
204
371
|
// Values follow the same HTML attribute conventions as the SSR `tagAttrs`: a string sets the value,
|
|
205
|
-
// `true` sets the bare boolean attribute, `false`/`undefined` skip it
|
|
372
|
+
// `true` sets the bare boolean attribute, `false`/`undefined` skip it - so a soft-nav head matches
|
|
206
373
|
// the server-rendered one exactly (no hydration drift on the managed tags).
|
|
207
|
-
const add = (tag: "meta" | "link", attrs:
|
|
374
|
+
const add = (tag: "meta" | "link", attrs: Readonly<object>): void => {
|
|
375
|
+
const trusted = trustedHeadAttributes(tag, attrs)
|
|
376
|
+
if (trusted === null) return
|
|
208
377
|
const el = document.createElement(tag)
|
|
209
|
-
for (const [name, value] of
|
|
210
|
-
if (value === undefined || value === false) continue
|
|
378
|
+
for (const [name, value] of trusted) {
|
|
211
379
|
el.setAttribute(name, value === true ? "" : value)
|
|
212
380
|
}
|
|
213
381
|
el.setAttribute("data-nifra", "")
|
|
@@ -215,13 +383,36 @@ export function applyHead(head: Meta): void {
|
|
|
215
383
|
}
|
|
216
384
|
for (const m of head.meta ?? []) add("meta", m)
|
|
217
385
|
for (const l of head.link ?? []) add("link", l)
|
|
218
|
-
// `<script>` slot (JSON-LD etc.)
|
|
386
|
+
// `<script>` slot (JSON-LD etc.) - soft-nav parity with the SSR `headTags` script render. Set the body
|
|
219
387
|
// via `textContent`, NOT `innerHTML`: `textContent` assigns the raw string without HTML re-parsing, so
|
|
220
|
-
// a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out
|
|
388
|
+
// a `</script>` (or `<!--`/`]]>`) payload in the JSON-LD can't break out - the DOM-native equivalent of
|
|
221
389
|
// the server's `escapeScriptContent`. `type` defaults to `application/ld+json` (matching the SSR side).
|
|
222
390
|
for (const s of head.script ?? []) {
|
|
391
|
+
const type = s.type ?? "application/ld+json"
|
|
392
|
+
if (!INERT_SCRIPT_TYPES.has(type)) {
|
|
393
|
+
throw new TypeError("[nifra/web] refusing executable content in the inert head.script slot")
|
|
394
|
+
}
|
|
395
|
+
const el = document.createElement("script")
|
|
396
|
+
el.setAttribute("type", type)
|
|
397
|
+
el.textContent = s.content
|
|
398
|
+
el.setAttribute("data-nifra", "")
|
|
399
|
+
document.head.appendChild(el)
|
|
400
|
+
}
|
|
401
|
+
for (const s of head.unsafeScript ?? []) {
|
|
402
|
+
if (s.unsafe !== true || s.nonce.trim() === "") {
|
|
403
|
+
throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
|
|
404
|
+
}
|
|
405
|
+
// Same allowlist the server applies. `setAttribute` cannot be broken out of the way the server's
|
|
406
|
+
// template literal could, so this is not about injection here - it is about the two halves
|
|
407
|
+
// accepting the same documents, so a head that renders cannot fail on the next soft-nav.
|
|
408
|
+
if (!EXECUTABLE_SCRIPT_TYPES.has(s.type)) {
|
|
409
|
+
throw new TypeError(
|
|
410
|
+
`[nifra/web] unsupported executable script type ${JSON.stringify(s.type)}`,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
223
413
|
const el = document.createElement("script")
|
|
224
|
-
el.setAttribute("type", s.type
|
|
414
|
+
el.setAttribute("type", s.type)
|
|
415
|
+
el.setAttribute("nonce", s.nonce)
|
|
225
416
|
el.textContent = s.content
|
|
226
417
|
el.setAttribute("data-nifra", "")
|
|
227
418
|
document.head.appendChild(el)
|
|
@@ -242,7 +433,7 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
242
433
|
// actionData drives the update); absent or any other value keeps the default revalidation.
|
|
243
434
|
const revalidate = form.dataset.nifraRevalidate !== "false"
|
|
244
435
|
router.submit(url.pathname + url.search, new FormData(form), { revalidate }).catch(() => {
|
|
245
|
-
form.submit() // data submit failed
|
|
436
|
+
form.submit() // data submit failed - fall back to a full-page POST
|
|
246
437
|
})
|
|
247
438
|
}
|
|
248
439
|
document.addEventListener("submit", onSubmit)
|
|
@@ -254,7 +445,7 @@ export function installForms(router: ClientRouter): () => void {
|
|
|
254
445
|
* and fires a one-shot `nifra:hydrated` event. The generated client entry calls this on the next frame
|
|
255
446
|
* after the adapter mounts (so every framework binding gets it), letting apps gate a custom JS-only
|
|
256
447
|
* interaction that would otherwise fire before its handler is attached. Idempotent. nifra's own
|
|
257
|
-
* progressive-enhancement forms/links don't need it
|
|
448
|
+
* progressive-enhancement forms/links don't need it - only hand-wired onClick/onSubmit handlers with no
|
|
258
449
|
* native fallback do. See the Hydration guide.
|
|
259
450
|
*/
|
|
260
451
|
export function signalHydrated(): void {
|
package/src/conformance.ts
CHANGED
|
@@ -93,8 +93,8 @@ const readHtml = async (
|
|
|
93
93
|
* and hydration-head output is a stable string. It rejects with
|
|
94
94
|
* {@link RenderAdapterConformanceError} naming the first failed check.
|
|
95
95
|
*
|
|
96
|
-
* Framework semantics outside the interface
|
|
97
|
-
* error boundaries, and framework-specific primitives
|
|
96
|
+
* Framework semantics outside the interface-Suspense scheduling, compiler plugins, DOM hydration,
|
|
97
|
+
* error boundaries, and framework-specific primitives-remain the adapter package's local tests.
|
|
98
98
|
*/
|
|
99
99
|
export async function assertRenderAdapterConformance(
|
|
100
100
|
adapter: RenderAdapter,
|
|
@@ -135,7 +135,7 @@ export async function assertRenderAdapterConformance(
|
|
|
135
135
|
if (typeof buffered !== "string") fail("buffered render", "renderToString must return a string")
|
|
136
136
|
const bufferedHtml = buffered as string
|
|
137
137
|
if (fixture.deferred) {
|
|
138
|
-
// Deferred content differs between streaming and buffered renders
|
|
138
|
+
// Deferred content differs between streaming and buffered renders - only require every marker.
|
|
139
139
|
for (const marker of [
|
|
140
140
|
markers.outer,
|
|
141
141
|
markers.inner,
|