netjes 0.0.1 → 0.0.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.
- package/README.md +11 -11
- package/cli/agents.mjs +25 -2
- package/cli/commands.mjs +56 -6
- package/cli/help.mjs +7 -2
- package/cli/info.mjs +7 -2
- package/cli/scaffold.mjs +31 -4
- package/dist/actions.d.ts +7 -2
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +9 -3
- package/dist/actions.js.map +1 -1
- package/dist/client-entry.d.ts +1 -0
- package/dist/client-entry.d.ts.map +1 -1
- package/dist/client-entry.js +43 -2
- package/dist/client-entry.js.map +1 -1
- package/dist/deferred.d.ts +11 -0
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +15 -1
- package/dist/deferred.js.map +1 -1
- package/dist/document.d.ts +4 -3
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +15 -5
- package/dist/document.js.map +1 -1
- package/dist/errors.d.ts +7 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +11 -0
- package/dist/errors.js.map +1 -0
- package/dist/form.d.ts +5 -5
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +5 -2
- package/dist/form.js.map +1 -1
- package/dist/href.d.ts +24 -3
- package/dist/href.d.ts.map +1 -1
- package/dist/href.js +117 -7
- package/dist/href.js.map +1 -1
- package/dist/image.d.ts +3 -3
- package/dist/image.d.ts.map +1 -1
- package/dist/image.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +15 -7
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +38 -7
- package/dist/link.js.map +1 -1
- package/dist/metadata.d.ts +8 -2
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +14 -5
- package/dist/metadata.js.map +1 -1
- package/dist/modules.d.ts +15 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js.map +1 -1
- package/dist/navigation-focus.d.ts +38 -0
- package/dist/navigation-focus.d.ts.map +1 -0
- package/dist/navigation-focus.js +71 -0
- package/dist/navigation-focus.js.map +1 -0
- package/dist/payload.d.ts +2 -0
- package/dist/payload.d.ts.map +1 -1
- package/dist/payload.js +2 -0
- package/dist/payload.js.map +1 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +11 -8
- package/dist/query.js.map +1 -1
- package/dist/render-document.d.ts +60 -0
- package/dist/render-document.d.ts.map +1 -0
- package/dist/render-document.js +330 -0
- package/dist/render-document.js.map +1 -0
- package/dist/route-announcer.d.ts +18 -0
- package/dist/route-announcer.d.ts.map +1 -0
- package/dist/route-announcer.js +58 -0
- package/dist/route-announcer.js.map +1 -0
- package/dist/route-tree.d.ts +6 -2
- package/dist/route-tree.d.ts.map +1 -1
- package/dist/route-tree.js +17 -7
- package/dist/route-tree.js.map +1 -1
- package/dist/router.d.ts +28 -5
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +71 -26
- package/dist/router.js.map +1 -1
- package/dist/script.d.ts +28 -5
- package/dist/script.d.ts.map +1 -1
- package/dist/script.js +192 -21
- package/dist/script.js.map +1 -1
- package/dist/server-render.d.ts.map +1 -1
- package/dist/server-render.js +4 -1
- package/dist/server-render.js.map +1 -1
- package/dist/testing-vitest.d.ts +8 -0
- package/dist/testing-vitest.d.ts.map +1 -0
- package/dist/testing-vitest.js +26 -0
- package/dist/testing-vitest.js.map +1 -0
- package/dist/testing.d.ts +11 -1
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +17 -3
- package/dist/testing.js.map +1 -1
- package/dist/zod-jitless.d.ts +2 -0
- package/dist/zod-jitless.d.ts.map +1 -0
- package/dist/zod-jitless.js +4 -0
- package/dist/zod-jitless.js.map +1 -0
- package/package.json +1 -1
- package/sidecar/server.mjs +73 -6
- package/skills/netjes/SKILL.md +13 -6
- package/skills/netjes/references/caching-and-streaming.md +37 -4
- package/skills/netjes/references/data-and-actions.md +9 -5
- package/skills/netjes/references/deployment.md +7 -3
- package/skills/netjes/references/routing.md +18 -6
- package/skills/netjes/references/testing.md +23 -7
- package/src/actions.tsx +9 -3
- package/src/client-entry.tsx +43 -2
- package/src/deferred.ts +14 -1
- package/src/document.tsx +28 -7
- package/src/errors.ts +12 -0
- package/src/form.tsx +10 -7
- package/src/href.ts +123 -8
- package/src/image.tsx +3 -3
- package/src/index.ts +2 -1
- package/src/link.tsx +47 -13
- package/src/metadata.tsx +19 -6
- package/src/modules.ts +15 -2
- package/src/navigation-focus.ts +75 -0
- package/src/payload.ts +2 -0
- package/src/query.tsx +14 -8
- package/src/render-document.tsx +386 -0
- package/src/route-announcer.tsx +69 -0
- package/src/route-tree.tsx +17 -6
- package/src/router.tsx +116 -31
- package/src/script.tsx +214 -23
- package/src/server-render.tsx +4 -1
- package/src/testing-vitest.ts +37 -0
- package/src/testing.tsx +31 -5
- package/src/virtual.d.ts +2 -0
- package/src/zod-jitless.ts +15 -0
- package/template/.dockerignore +3 -0
- package/template/Dockerfile +11 -1
- package/template/README.md +11 -7
- package/template/_AGENTS.md +36 -8
- package/template/__NAME__.csproj +2 -0
- package/template/app/document.test.ts +29 -0
- package/template/app/error.tsx +3 -2
- package/template/app/layout.tsx +10 -1
- package/template/docker-compose.override.yml +4 -3
- package/template/docker-compose.prod.yml +2 -1
- package/template/e2e/home.spec.ts +32 -0
- package/template/generated/netjes.ts +43 -14
- package/template/package.json +4 -1
- package/template/playwright.config.ts +36 -0
- package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
- package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
- package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
- package/template/tsconfig.app.json +20 -0
- package/template/tsconfig.json +2 -17
- package/template/tsconfig.node.json +8 -0
- package/vite/plugin.d.ts +9 -1
- package/vite/plugin.mjs +17 -5
package/src/router.tsx
CHANGED
|
@@ -9,13 +9,15 @@ import {
|
|
|
9
9
|
useEffect,
|
|
10
10
|
useLayoutEffect,
|
|
11
11
|
useMemo,
|
|
12
|
+
useRef,
|
|
12
13
|
useState,
|
|
13
14
|
} from 'react';
|
|
14
15
|
import { actionRequest, expectActionClient, fetchAction, setActionClient } from './actions.tsx';
|
|
15
16
|
import { deferredRegistry, revivePayload } from './deferred.ts';
|
|
16
17
|
import type { EncodedForm } from './form-data.ts';
|
|
17
18
|
import type { Href } from './href.ts';
|
|
18
|
-
import { MissingModuleError, type ModuleLoader, type RouteModules } from './modules.ts';
|
|
19
|
+
import { type ErrorPageProps, MissingModuleError, type ModuleLoader, type RouteModules } from './modules.ts';
|
|
20
|
+
import { moveFocusAfterNavigation } from './navigation-focus.ts';
|
|
19
21
|
import {
|
|
20
22
|
type ActionResponse,
|
|
21
23
|
type AntiforgeryToken,
|
|
@@ -31,7 +33,8 @@ import {
|
|
|
31
33
|
parseAntiforgery,
|
|
32
34
|
parseNavigationPayload,
|
|
33
35
|
} from './payload.ts';
|
|
34
|
-
import {
|
|
36
|
+
import { RouteAnnouncer } from './route-announcer.tsx';
|
|
37
|
+
import { ErrorBoundary, LayoutDepthContext, RouteTree, asError } from './route-tree.tsx';
|
|
35
38
|
import { type StandardSchemaV1, parseLenient, parseWith } from './schema.ts';
|
|
36
39
|
|
|
37
40
|
/** A form post that did not produce a page: a non-page error response (`status`), or no response at all (`null`). */
|
|
@@ -44,11 +47,19 @@ export type NavigationState =
|
|
|
44
47
|
|
|
45
48
|
/** The client router that `useRouter()` returns. */
|
|
46
49
|
export type Router = {
|
|
50
|
+
/**
|
|
51
|
+
* Navigates, adding a history entry. The promise resolves once the router has handed the page to React, which can be
|
|
52
|
+
* before React shows it, or once the navigation has ended another way.
|
|
53
|
+
*/
|
|
47
54
|
push(href: Href, options?: { scroll?: boolean }): Promise<void>;
|
|
55
|
+
/** Navigates, replacing the history entry. The promise resolves as `push`'s does. */
|
|
48
56
|
replace(href: Href, options?: { scroll?: boolean }): Promise<void>;
|
|
49
|
-
/** Fetches the page payload and modules ahead of a navigation. Links do this on hover and
|
|
57
|
+
/** Fetches the page payload and modules ahead of a navigation. Links do this on hover, focus and touch. */
|
|
50
58
|
prefetch(href: Href): void;
|
|
51
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Runs every loader of the page on screen again, layout loaders included, and renders the page with the new props, in
|
|
61
|
+
* the same history entry and without scrolling. The promise resolves as `push`'s does.
|
|
62
|
+
*/
|
|
52
63
|
refresh(): Promise<void>;
|
|
53
64
|
back(): void;
|
|
54
65
|
forward(): void;
|
|
@@ -59,8 +70,17 @@ export type SubmitBody = EncodedForm;
|
|
|
59
70
|
/** Called with every server action response, e.g. by the query shell to apply query updates. */
|
|
60
71
|
export type ActionListener = (response: ActionResponse) => void;
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
/**
|
|
74
|
+
* What `<Link>` and `<Form>` also pass: what had the focus before the app's own `onClick` or `onSubmit` ran. Those run
|
|
75
|
+
* as the navigation starts, so where they move the focus, such as back to the button of a menu that closes, is where
|
|
76
|
+
* the navigation put it.
|
|
77
|
+
*/
|
|
78
|
+
type FocusAtStart = { focusedAtStart?: Element | null };
|
|
79
|
+
|
|
80
|
+
export type InternalRouter = Omit<Router, 'push' | 'replace'> & {
|
|
81
|
+
push(href: Href, options?: { scroll?: boolean } & FocusAtStart): Promise<void>;
|
|
82
|
+
replace(href: Href, options?: { scroll?: boolean } & FocusAtStart): Promise<void>;
|
|
83
|
+
submit(action: URL, body: SubmitBody, options?: FocusAtStart): Promise<void>;
|
|
64
84
|
onActionResponse(listener: ActionListener): () => void;
|
|
65
85
|
/**
|
|
66
86
|
* Called as soon as a server action call or form post says it set or deleted a cookie (signed someone in or out),
|
|
@@ -89,6 +109,13 @@ type PageRequest = { method: 'GET' } | ({ method: 'POST' } & SubmitBody);
|
|
|
89
109
|
function isLoaded(result: LoadResult): result is Loaded {
|
|
90
110
|
return 'payload' in result;
|
|
91
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What remounts the page and its templates when it changes: the page's path, or for an intercepted page the key of the
|
|
115
|
+
* page under it, which stays on screen. Two views with one key show the same page.
|
|
116
|
+
*/
|
|
117
|
+
const pageKeyOf = (loaded: Loaded) => loaded.pageKey ?? loaded.url.pathname;
|
|
118
|
+
|
|
92
119
|
type ScrollIntent = { kind: 'none' } | { kind: 'top' } | { kind: 'hash'; hash: string } | { kind: 'restore'; x: number; y: number };
|
|
93
120
|
type HistoryMode = 'push' | 'replace' | 'none' | 'auto';
|
|
94
121
|
|
|
@@ -97,6 +124,13 @@ const ANTIFORGERY_URL = '/_netjes/antiforgery';
|
|
|
97
124
|
const NONE: ScrollIntent = { kind: 'none' };
|
|
98
125
|
const TOP: ScrollIntent = { kind: 'top' };
|
|
99
126
|
const STATE_KEY = '__netjes';
|
|
127
|
+
/**
|
|
128
|
+
* On `<html>` while the client router runs in the document, and the public sign that it does: end-to-end tests wait
|
|
129
|
+
* for it before they click. (The history entry's key is no such sign: the entry keeps it when the browser loads the
|
|
130
|
+
* document again.) The effect that sets it must stay after the one that registers the router as the action client,
|
|
131
|
+
* so that once it shows, the router has keyed the entry, listens for back and forward, and runs server action calls.
|
|
132
|
+
*/
|
|
133
|
+
const HYDRATED_ATTRIBUTE = 'data-netjes-hydrated';
|
|
100
134
|
const PREFETCH_TTL_MS = 30_000;
|
|
101
135
|
const MAX_REDIRECTS = 10;
|
|
102
136
|
const MAX_REMEMBERED_ENTRIES = 50;
|
|
@@ -281,7 +315,8 @@ type NavigatorDeps = {
|
|
|
281
315
|
showDocument(html: string, url: string): void;
|
|
282
316
|
download(file: Blob, name: string): void;
|
|
283
317
|
setPending(state: NavigationState): void;
|
|
284
|
-
|
|
318
|
+
/** `focusedAtStart`: what had the focus when the navigation started, which decides where the focus goes after it. */
|
|
319
|
+
commit(loaded: Loaded, scroll: ScrollIntent, focusedAtStart: Element | null): void;
|
|
285
320
|
};
|
|
286
321
|
|
|
287
322
|
type GoOptions = {
|
|
@@ -294,6 +329,8 @@ type GoOptions = {
|
|
|
294
329
|
reuseSegments: boolean;
|
|
295
330
|
/** Refreshing an intercepted page: load this page under it again too, and show the result in its slot again. */
|
|
296
331
|
interceptOver?: URL;
|
|
332
|
+
/** What had the focus as the navigation started, when the caller read it: else go() reads it as it starts. */
|
|
333
|
+
focusedAtStart?: Element | null;
|
|
297
334
|
};
|
|
298
335
|
|
|
299
336
|
/**
|
|
@@ -369,7 +406,8 @@ class Navigator {
|
|
|
369
406
|
this.channel = null;
|
|
370
407
|
}
|
|
371
408
|
|
|
372
|
-
|
|
409
|
+
/** `focusedAtStart`: what had the focus as the navigation started, by default what has it as this is called. */
|
|
410
|
+
async navigate(href: string, history: 'push' | 'replace', scroll: boolean, focusedAtStart = document.activeElement): Promise<void> {
|
|
373
411
|
const url = new URL(withBasePath(href, this.deps.basePath), window.location.href);
|
|
374
412
|
if (!isWebUrl(url)) {
|
|
375
413
|
console.error(`netjes: refusing to navigate to ${url.protocol} URL`);
|
|
@@ -387,7 +425,7 @@ class Navigator {
|
|
|
387
425
|
const key = createKey();
|
|
388
426
|
window.history[history === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: key }, '', url.href);
|
|
389
427
|
this.setCurrent(key, { ...this.current, url });
|
|
390
|
-
this.deps.commit(this.current, scroll ? { kind: 'hash', hash: url.hash } : NONE);
|
|
428
|
+
this.deps.commit(this.current, scroll ? { kind: 'hash', hash: url.hash } : NONE, focusedAtStart);
|
|
391
429
|
return;
|
|
392
430
|
}
|
|
393
431
|
|
|
@@ -397,11 +435,16 @@ class Navigator {
|
|
|
397
435
|
pending: 'loading',
|
|
398
436
|
usePrefetch: true,
|
|
399
437
|
reuseSegments: true,
|
|
438
|
+
focusedAtStart,
|
|
400
439
|
});
|
|
401
440
|
}
|
|
402
441
|
|
|
403
|
-
submit(action: URL, body: SubmitBody): Promise<void> {
|
|
404
|
-
return this.go(
|
|
442
|
+
submit(action: URL, body: SubmitBody, focusedAtStart = document.activeElement): Promise<void> {
|
|
443
|
+
return this.go(
|
|
444
|
+
action,
|
|
445
|
+
{ method: 'POST', ...body },
|
|
446
|
+
{ history: 'auto', scroll: 'auto', pending: 'submitting', usePrefetch: false, reuseSegments: false, focusedAtStart },
|
|
447
|
+
);
|
|
405
448
|
}
|
|
406
449
|
|
|
407
450
|
/**
|
|
@@ -553,13 +596,17 @@ class Navigator {
|
|
|
553
596
|
if (known) {
|
|
554
597
|
this.active?.controller.abort();
|
|
555
598
|
this.setCurrent(key, { ...known, url });
|
|
556
|
-
this.deps.commit(this.current, scroll);
|
|
599
|
+
this.deps.commit(this.current, scroll, document.activeElement);
|
|
557
600
|
return;
|
|
558
601
|
}
|
|
559
602
|
void this.go(url, { method: 'GET' }, { history: 'none', scroll, pending: 'loading', key, usePrefetch: false, reuseSegments: true });
|
|
560
603
|
}
|
|
561
604
|
|
|
562
605
|
private async go(url: URL, request: PageRequest, options: GoOptions): Promise<void> {
|
|
606
|
+
// Read before anything this navigation starts can move it: inside the click (or the keypress) that started it,
|
|
607
|
+
// before React has handled that event's own state updates, such as a menu that closes. navigate() and submit()
|
|
608
|
+
// pass what they read, or what a link or a form read before the app's own handler ran.
|
|
609
|
+
const focusedAtStart = options.focusedAtStart === undefined ? document.activeElement : options.focusedAtStart;
|
|
563
610
|
if (url.origin !== window.location.origin) {
|
|
564
611
|
// navigate() and Form hand other origins to the browser, and every page URL the router keeps is on this one.
|
|
565
612
|
// Refuse one that gets here all the same: a failed load would send the browser to it.
|
|
@@ -638,7 +685,7 @@ class Navigator {
|
|
|
638
685
|
this.forgetToken();
|
|
639
686
|
committed = true;
|
|
640
687
|
const scroll = shown.base ? NONE : options.scroll === 'auto' ? (shown.url.pathname === previous.pathname ? NONE : TOP) : options.scroll;
|
|
641
|
-
this.deps.commit(shown, scroll);
|
|
688
|
+
this.deps.commit(shown, scroll, focusedAtStart);
|
|
642
689
|
} catch (error) {
|
|
643
690
|
if (controller.signal.aborted) return navigation.restarted;
|
|
644
691
|
if (request.method === 'POST') {
|
|
@@ -809,21 +856,22 @@ function elementForFragment(fragment: string): HTMLElement | null {
|
|
|
809
856
|
return document.getElementById(decoded) ?? document.getElementById(fragment);
|
|
810
857
|
}
|
|
811
858
|
|
|
812
|
-
|
|
859
|
+
/** Scrolls as the intent says, and returns the element a fragment names, which then takes the focus. */
|
|
860
|
+
function applyScroll(intent: ScrollIntent): HTMLElement | null {
|
|
813
861
|
switch (intent.kind) {
|
|
814
862
|
case 'none':
|
|
815
|
-
return;
|
|
863
|
+
return null;
|
|
816
864
|
case 'top':
|
|
817
865
|
window.scrollTo(0, 0);
|
|
818
|
-
return;
|
|
866
|
+
return null;
|
|
819
867
|
case 'restore':
|
|
820
868
|
window.scrollTo(intent.x, intent.y);
|
|
821
|
-
return;
|
|
869
|
+
return null;
|
|
822
870
|
case 'hash': {
|
|
823
871
|
const target = elementForFragment(intent.hash.slice(1));
|
|
824
872
|
if (target) target.scrollIntoView();
|
|
825
873
|
else window.scrollTo(0, 0);
|
|
826
|
-
return;
|
|
874
|
+
return target;
|
|
827
875
|
}
|
|
828
876
|
default: {
|
|
829
877
|
const exhaustive: never = intent;
|
|
@@ -832,7 +880,7 @@ function applyScroll(intent: ScrollIntent) {
|
|
|
832
880
|
}
|
|
833
881
|
}
|
|
834
882
|
|
|
835
|
-
type View = { loaded: Loaded; id: number; scroll: ScrollIntent };
|
|
883
|
+
type View = { loaded: Loaded; id: number; scroll: ScrollIntent; focusedAtStart: Element | null };
|
|
836
884
|
type ViewValue = { payload: PagePayload; url: URL };
|
|
837
885
|
|
|
838
886
|
const RouterContext = createContext<InternalRouter | null>(null);
|
|
@@ -896,6 +944,7 @@ export function RouterProvider({
|
|
|
896
944
|
},
|
|
897
945
|
id: 0,
|
|
898
946
|
scroll: NONE,
|
|
947
|
+
focusedAtStart: null,
|
|
899
948
|
}));
|
|
900
949
|
const [navigation, setNavigation] = useState<NavigationState>(IDLE);
|
|
901
950
|
const [navigator] = useState(() => {
|
|
@@ -910,9 +959,9 @@ export function RouterProvider({
|
|
|
910
959
|
showDocument,
|
|
911
960
|
download,
|
|
912
961
|
setPending: setNavigation,
|
|
913
|
-
commit: (loaded, scroll) =>
|
|
962
|
+
commit: (loaded, scroll, focusedAtStart) =>
|
|
914
963
|
startTransition(() => {
|
|
915
|
-
setView((previous) => ({ loaded, id: previous.id + 1, scroll }));
|
|
964
|
+
setView((previous) => ({ loaded, id: previous.id + 1, scroll, focusedAtStart }));
|
|
916
965
|
setNavigation(IDLE);
|
|
917
966
|
}),
|
|
918
967
|
},
|
|
@@ -974,19 +1023,37 @@ export function RouterProvider({
|
|
|
974
1023
|
}),
|
|
975
1024
|
[navigator, actionListeners],
|
|
976
1025
|
);
|
|
1026
|
+
// Directly after the registration above, and in a passive effect, never a layout one: React runs a component's
|
|
1027
|
+
// effects in order, a commit's layout effects before any passive one, and the mark must not show while server
|
|
1028
|
+
// action calls still wait for the router.
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
const html = document.documentElement;
|
|
1031
|
+
html.setAttribute(HYDRATED_ATTRIBUTE, '');
|
|
1032
|
+
return () => html.removeAttribute(HYDRATED_ATTRIBUTE);
|
|
1033
|
+
}, []);
|
|
1034
|
+
// The key of the page on screen before this commit.
|
|
1035
|
+
const committedPageKey = useRef(pageKeyOf(view.loaded));
|
|
1036
|
+
// In the commit that shows the page, before the browser paints it, and after the layout effects of the page and its
|
|
1037
|
+
// layouts, which have focused an autoFocus element by then. Every focus call keeps the scroll position applyScroll
|
|
1038
|
+
// sets, the one Back restores included.
|
|
977
1039
|
useLayoutEffect(() => {
|
|
978
|
-
if (view.id
|
|
1040
|
+
if (view.id === 0) return;
|
|
1041
|
+
const target = applyScroll(view.scroll);
|
|
1042
|
+
const key = pageKeyOf(view.loaded);
|
|
1043
|
+
const replaced = key !== committedPageKey.current;
|
|
1044
|
+
committedPageKey.current = key;
|
|
1045
|
+
moveFocusAfterNavigation({ target, replaced, keep: view.scroll.kind === 'none', focusedAtStart: view.focusedAtStart });
|
|
979
1046
|
}, [view]);
|
|
980
1047
|
|
|
981
1048
|
const router = useMemo<InternalRouter>(
|
|
982
1049
|
() => ({
|
|
983
|
-
push: (href, options) => navigator.navigate(href, 'push', options?.scroll ?? true),
|
|
984
|
-
replace: (href, options) => navigator.navigate(href, 'replace', options?.scroll ?? true),
|
|
1050
|
+
push: (href, options) => navigator.navigate(href, 'push', options?.scroll ?? true, options?.focusedAtStart),
|
|
1051
|
+
replace: (href, options) => navigator.navigate(href, 'replace', options?.scroll ?? true, options?.focusedAtStart),
|
|
985
1052
|
prefetch: (href) => navigator.prefetch(href),
|
|
986
1053
|
refresh: () => navigator.refresh(),
|
|
987
1054
|
back: () => window.history.back(),
|
|
988
1055
|
forward: () => window.history.forward(),
|
|
989
|
-
submit: (action, body) => navigator.submit(action, body),
|
|
1056
|
+
submit: (action, body, options) => navigator.submit(action, body, options?.focusedAtStart),
|
|
990
1057
|
onActionResponse: (listener) => {
|
|
991
1058
|
actionListeners.add(listener);
|
|
992
1059
|
return () => actionListeners.delete(listener);
|
|
@@ -1014,12 +1081,15 @@ export function RouterProvider({
|
|
|
1014
1081
|
params={payload.params}
|
|
1015
1082
|
status={payload.status}
|
|
1016
1083
|
pageKind={payload.pageKind}
|
|
1017
|
-
pageKey={view.loaded
|
|
1084
|
+
pageKey={pageKeyOf(view.loaded)}
|
|
1018
1085
|
retry={() => void router.refresh()}
|
|
1019
1086
|
metadata={payload.metadata}
|
|
1020
1087
|
/>
|
|
1021
1088
|
</ErrorBoundary>
|
|
1022
1089
|
</Shell>
|
|
1090
|
+
{/* Outside the app's shell and error boundaries, which cannot remove it, and after them, so it reads what the
|
|
1091
|
+
new page has set. */}
|
|
1092
|
+
<RouteAnnouncer location={url.pathname + url.search} path={withoutBasePath(url.pathname, payload.basePath ?? '')} />
|
|
1023
1093
|
</NavigationContext>
|
|
1024
1094
|
</ViewContext>
|
|
1025
1095
|
</RouterContext>
|
|
@@ -1028,8 +1098,13 @@ export function RouterProvider({
|
|
|
1028
1098
|
|
|
1029
1099
|
const errorPageModules = new WeakMap<ModuleLoader, Promise<RouteModules>>();
|
|
1030
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* What the router shows for an error no segment's boundary caught: the app's root error page, handed the error as it
|
|
1103
|
+
* was thrown (its class, its stack, a server action's status), or the built-in page, which says 500.
|
|
1104
|
+
*/
|
|
1031
1105
|
function ErrorFallback({ error, errorPage, loader, payload }: { error: unknown; errorPage: SpecialPageRef | null; loader: ModuleLoader; payload: PagePayload }) {
|
|
1032
|
-
const
|
|
1106
|
+
const caught = asError(error);
|
|
1107
|
+
const props = { status: 500, message: caught.message };
|
|
1033
1108
|
const builtIn = <RouteTree modules={{ page: null, segments: [] }} props={props} params={payload.params} status={500} pageKey="error" />;
|
|
1034
1109
|
if (!errorPage) return builtIn;
|
|
1035
1110
|
let modules = errorPageModules.get(loader);
|
|
@@ -1041,14 +1116,24 @@ function ErrorFallback({ error, errorPage, loader, payload }: { error: unknown;
|
|
|
1041
1116
|
// If the error page (or a layout around it) throws as well, fall back to the built-in page.
|
|
1042
1117
|
<ErrorBoundary resetKey={0} fallback={() => builtIn}>
|
|
1043
1118
|
<Suspense fallback={null}>
|
|
1044
|
-
<LoadedErrorPage modules={modules} props={props} payload={payload} />
|
|
1119
|
+
<LoadedErrorPage modules={modules} props={props} error={caught} payload={payload} />
|
|
1045
1120
|
</Suspense>
|
|
1046
1121
|
</ErrorBoundary>
|
|
1047
1122
|
);
|
|
1048
1123
|
}
|
|
1049
1124
|
|
|
1050
|
-
function LoadedErrorPage({
|
|
1051
|
-
|
|
1125
|
+
function LoadedErrorPage({
|
|
1126
|
+
modules,
|
|
1127
|
+
props,
|
|
1128
|
+
error,
|
|
1129
|
+
payload,
|
|
1130
|
+
}: {
|
|
1131
|
+
modules: Promise<RouteModules>;
|
|
1132
|
+
props: Record<string, unknown>;
|
|
1133
|
+
error: ErrorPageProps['error'];
|
|
1134
|
+
payload: PagePayload;
|
|
1135
|
+
}) {
|
|
1136
|
+
return <RouteTree modules={use(modules)} props={props} params={payload.params} status={500} pageKind="error" pageKey="error" error={error} />;
|
|
1052
1137
|
}
|
|
1053
1138
|
|
|
1054
1139
|
/** The router's contexts without the router: for the test harness (netjes/testing), which fakes navigation. */
|
|
@@ -1098,7 +1183,7 @@ export function usePathname(): string {
|
|
|
1098
1183
|
return withoutBasePath(url.pathname, payload.basePath ?? '');
|
|
1099
1184
|
}
|
|
1100
1185
|
|
|
1101
|
-
/** An href for a plain `<a
|
|
1186
|
+
/** An href for a plain `<a>`, another attribute or a `fetch`, under the base path: what `<Link>` does for its own. */
|
|
1102
1187
|
export function useHref(href: string): string {
|
|
1103
1188
|
return withBasePath(href, useBasePath());
|
|
1104
1189
|
}
|
package/src/script.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type ScriptHTMLAttributes, useEffect, useRef } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { type ScriptHTMLAttributes, useEffect, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import { preload } from 'react-dom';
|
|
3
|
+
import { htmlSafeJson } from './deferred.ts';
|
|
3
4
|
import { useNonce } from './nonce.tsx';
|
|
4
5
|
|
|
5
6
|
/** When `<Script>` loads its script: before or after the page is interactive, or when the browser is idle. */
|
|
@@ -7,11 +8,17 @@ export type ScriptStrategy = 'beforeInteractive' | 'afterInteractive' | 'lazyOnl
|
|
|
7
8
|
|
|
8
9
|
/** The props of `<Script>`: a script's own, with a loading strategy and load callbacks. */
|
|
9
10
|
export type ScriptProps = Omit<ScriptHTMLAttributes<HTMLScriptElement>, 'onLoad' | 'onError' | 'children' | 'dangerouslySetInnerHTML'> & {
|
|
10
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Identifies the script (required for inline scripts): it is added once, however often it is rendered, except inline
|
|
13
|
+
* `beforeInteractive` code, which the server writes wherever a component renders it.
|
|
14
|
+
*/
|
|
11
15
|
id?: string;
|
|
12
16
|
/**
|
|
13
17
|
* `afterInteractive` (default): once the page is interactive. `lazyOnload`: when the browser is idle.
|
|
14
|
-
* `beforeInteractive`: in
|
|
18
|
+
* `beforeInteractive`: in a server-rendered document, inline code runs where it stands, as the parser reaches it,
|
|
19
|
+
* and an external script in the shell runs once the shell is parsed, in order with the others, before the page's
|
|
20
|
+
* code and hydration; one in a Suspense boundary that streams in later runs when that part of the page hydrates.
|
|
21
|
+
* A component that mounts in the browser, after a client navigation for one, adds it as `afterInteractive` does.
|
|
15
22
|
*/
|
|
16
23
|
strategy?: ScriptStrategy;
|
|
17
24
|
onLoad?: (event: Event) => void;
|
|
@@ -20,9 +27,18 @@ export type ScriptProps = Omit<ScriptHTMLAttributes<HTMLScriptElement>, 'onLoad'
|
|
|
20
27
|
children?: string;
|
|
21
28
|
};
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The name of the `<meta>` a server-rendered `beforeInteractive` script puts in the head, which tells the client entry
|
|
32
|
+
* to run the external ones before it imports the page's modules.
|
|
33
|
+
*/
|
|
34
|
+
export const BEFORE_INTERACTIVE_MARKER = 'netjes-before-interactive';
|
|
35
|
+
|
|
36
|
+
/** The attribute that marks a server-rendered `beforeInteractive` script, inline or inert, with its id or src. */
|
|
37
|
+
const ENTRY = 'data-netjes-script';
|
|
38
|
+
|
|
23
39
|
type Entry = { element: HTMLScriptElement; state: 'loading' | 'loaded' | 'failed'; event?: Event };
|
|
24
40
|
|
|
25
|
-
/** Scripts already added to this document, by id or src. */
|
|
41
|
+
/** Scripts already added to this document, or run by its parser, by id or src. */
|
|
26
42
|
const added = new Map<string, Entry>();
|
|
27
43
|
|
|
28
44
|
function whenIdle(run: () => void) {
|
|
@@ -32,52 +48,200 @@ function whenIdle(run: () => void) {
|
|
|
32
48
|
else window.addEventListener('load', schedule, { once: true });
|
|
33
49
|
}
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
/** A script's own attributes by their HTML names: strings, or true for one without a value. */
|
|
52
|
+
type Attributes = Record<string, string | true>;
|
|
53
|
+
|
|
54
|
+
/** A name `setAttribute` takes, other than an event handler's, which React never renders from a string either. */
|
|
55
|
+
const isAttributeName = (name: string) => /^[A-Za-z_:][-\w.:]*$/.test(name) && !/^on/i.test(name);
|
|
56
|
+
|
|
57
|
+
function attributesOf(props: Record<string, unknown>): Attributes {
|
|
58
|
+
const attributes: Attributes = {};
|
|
59
|
+
for (const [name, value] of Object.entries(props)) {
|
|
60
|
+
if (value === undefined || value === null || value === false || typeof value === 'function' || typeof value === 'object') continue;
|
|
61
|
+
const html = name === 'className' ? 'class' : name === 'crossOrigin' ? 'crossorigin' : name;
|
|
62
|
+
if (isAttributeName(html)) attributes[html] = value === true ? true : String(value);
|
|
40
63
|
}
|
|
64
|
+
return attributes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Insertion = { src?: string; code?: string; id?: string; nonce?: string; attributes: Attributes; async: boolean; parent: HTMLElement };
|
|
68
|
+
|
|
69
|
+
/** Adds a script element to the document and records it, with its outcome once it has one. */
|
|
70
|
+
function insert(key: string, { src, code, id, nonce, attributes, async, parent }: Insertion): Entry {
|
|
71
|
+
const element = document.createElement('script');
|
|
72
|
+
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value === true ? '' : value);
|
|
41
73
|
if (id) element.id = id;
|
|
42
74
|
// The property, not the attribute: browsers hide nonce attributes once a policy applies.
|
|
43
|
-
if (
|
|
75
|
+
if (nonce) element.nonce = nonce;
|
|
44
76
|
const entry: Entry = { element, state: 'loading' };
|
|
45
77
|
element.addEventListener('load', (event) => Object.assign(entry, { state: 'loaded', event }));
|
|
46
78
|
element.addEventListener('error', (event) => Object.assign(entry, { state: 'failed', event }));
|
|
47
79
|
if (src) {
|
|
48
80
|
element.src = src;
|
|
49
|
-
element.async =
|
|
81
|
+
element.async = async;
|
|
50
82
|
} else {
|
|
51
|
-
element.textContent =
|
|
83
|
+
element.textContent = code ?? '';
|
|
52
84
|
// Inline scripts run as they are appended; there is no load event to wait for.
|
|
53
85
|
entry.state = 'loaded';
|
|
54
86
|
}
|
|
55
87
|
added.set(key, entry);
|
|
56
|
-
|
|
88
|
+
parent.append(element);
|
|
57
89
|
return entry;
|
|
58
90
|
}
|
|
59
91
|
|
|
92
|
+
function add(key: string, { src, children, strategy, id, onLoad: _onLoad, onError: _onError, ...rest }: ScriptProps): Entry {
|
|
93
|
+
// Inline beforeInteractive code as the server's document carries it, so that one hash allows it on either path.
|
|
94
|
+
const code = strategy === 'beforeInteractive' ? escapeScriptText(children ?? '') : (children ?? '');
|
|
95
|
+
return insert(key, {
|
|
96
|
+
...(src ? { src } : { code }),
|
|
97
|
+
...(id ? { id } : {}),
|
|
98
|
+
...(rest.nonce ? { nonce: rest.nonce } : {}),
|
|
99
|
+
attributes: attributesOf(rest),
|
|
100
|
+
async: true,
|
|
101
|
+
parent: document.body,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What an inert entry holds, the script's src and its attributes, or null when it holds anything else. */
|
|
106
|
+
function externalEntry(text: string | null): { src: string; attributes: Attributes } | null {
|
|
107
|
+
let value: unknown;
|
|
108
|
+
try {
|
|
109
|
+
value = JSON.parse(text ?? '');
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
114
|
+
const { src, attributes } = value as { src?: unknown; attributes?: unknown };
|
|
115
|
+
if (typeof src !== 'string' || src === '') return null;
|
|
116
|
+
const checked: Attributes = {};
|
|
117
|
+
if (typeof attributes === 'object' && attributes !== null) {
|
|
118
|
+
for (const [name, attribute] of Object.entries(attributes)) {
|
|
119
|
+
if ((typeof attribute === 'string' || attribute === true) && isAttributeName(name) && name.toLowerCase() !== 'nonce') checked[name] = attribute;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { src, attributes: checked };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The types besides none that browsers run as JavaScript: the HTML standard's JavaScript MIME types. */
|
|
126
|
+
const JAVASCRIPT_TYPES = new Set([
|
|
127
|
+
'application/ecmascript',
|
|
128
|
+
'application/javascript',
|
|
129
|
+
'application/x-ecmascript',
|
|
130
|
+
'application/x-javascript',
|
|
131
|
+
'text/ecmascript',
|
|
132
|
+
'text/javascript',
|
|
133
|
+
'text/javascript1.0',
|
|
134
|
+
'text/javascript1.1',
|
|
135
|
+
'text/javascript1.2',
|
|
136
|
+
'text/javascript1.3',
|
|
137
|
+
'text/javascript1.4',
|
|
138
|
+
'text/javascript1.5',
|
|
139
|
+
'text/jscript',
|
|
140
|
+
'text/livescript',
|
|
141
|
+
'text/x-ecmascript',
|
|
142
|
+
'text/x-javascript',
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether a browser runs a script with these attributes, by the HTML standard's rules, and so fires its load or error
|
|
147
|
+
* event. One that runs the client entry, a module, skips a script with `nomodule` and one whose type is neither
|
|
148
|
+
* JavaScript nor `module`, such as the `text/plain` of a script a consent manager turns on once the visitor agrees, and
|
|
149
|
+
* fires neither event for it.
|
|
150
|
+
*/
|
|
151
|
+
function runs(attributes: Attributes): boolean {
|
|
152
|
+
const named: Partial<Record<string, string>> = {};
|
|
153
|
+
for (const [name, value] of Object.entries(attributes)) named[name.toLowerCase()] = value === true ? '' : value;
|
|
154
|
+
const type = named.type ?? (named.language ? `text/${named.language}` : '');
|
|
155
|
+
if (type === '') return named.nomodule === undefined;
|
|
156
|
+
const essence = type.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/g, '').toLowerCase();
|
|
157
|
+
return essence === 'module' || (JAVASCRIPT_TYPES.has(essence) && named.nomodule === undefined);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolves once the script has loaded or failed. */
|
|
161
|
+
function outcome(entry: Entry): Promise<void> {
|
|
162
|
+
if (entry.state !== 'loading') return Promise.resolve();
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
entry.element.addEventListener('load', () => resolve(), { once: true });
|
|
165
|
+
entry.element.addEventListener('error', () => resolve(), { once: true });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
60
169
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
170
|
+
* Takes over the `beforeInteractive` scripts the server rendered into the document, in document order, those not yet
|
|
171
|
+
* taken over. Inline code has run where it stands: it is recorded, so that a component that mounts again (after a
|
|
172
|
+
* hydration error) does not add it once more. Each external script's inert entry becomes a script at the end of the
|
|
173
|
+
* head, not async, so the browser runs them in this order as they arrive, with the page's nonce. Under a nonce, only
|
|
174
|
+
* entries with that nonce count: markup that reached the page some other way does not know it. Resolves once every
|
|
175
|
+
* external script it added has loaded or failed.
|
|
176
|
+
*/
|
|
177
|
+
export function adoptServerScripts(nonce: string | undefined): Promise<void> {
|
|
178
|
+
const waits: Promise<void>[] = [];
|
|
179
|
+
for (const element of document.querySelectorAll<HTMLScriptElement>(`script[${ENTRY}]`)) {
|
|
180
|
+
const key = element.getAttribute(ENTRY);
|
|
181
|
+
if (!key || added.has(key) || (nonce && element.nonce !== nonce)) continue;
|
|
182
|
+
const external = element.type === 'application/json' ? externalEntry(element.textContent) : null;
|
|
183
|
+
if (external) {
|
|
184
|
+
const entry = insert(key, { ...external, ...(nonce ? { nonce } : {}), async: false, parent: document.head });
|
|
185
|
+
if (runs(external.attributes)) waits.push(outcome(entry));
|
|
186
|
+
} else {
|
|
187
|
+
added.set(key, { element, state: 'loaded' });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return Promise.all(waits).then(() => undefined);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* React's own rule for the text of an inline script: `</script` would close the element and `<script` would make the
|
|
195
|
+
* parser look for a second closing tag, so their `s` is written `\u0073` (or `S` as `\u0053`), which JavaScript reads as the
|
|
196
|
+
* same letter in strings, template literals, regular expressions and names.
|
|
197
|
+
*/
|
|
198
|
+
const SCRIPT_TAG = /(<\/|<)(s)(cript)/gi;
|
|
199
|
+
const escapeScriptText = (code: string) =>
|
|
200
|
+
code.replace(SCRIPT_TAG, (_match, open: string, s: string, rest: string) => `${open}${s === 's' ? '\\u0073' : '\\u0053'}${rest}`);
|
|
201
|
+
|
|
202
|
+
const subscribeToNothing = () => () => {};
|
|
203
|
+
|
|
204
|
+
/** Scripts warned about already: each is warned about once. */
|
|
205
|
+
const warned = new Set<string>();
|
|
206
|
+
|
|
207
|
+
function warnAboutTiming(key: string, { async, defer, type }: ScriptProps) {
|
|
208
|
+
if (process.env.NODE_ENV === 'production' || warned.has(key)) return;
|
|
209
|
+
const found = [async && 'async', defer && 'defer', type === 'module' && 'type="module"'].filter(Boolean);
|
|
210
|
+
if (found.length === 0) return;
|
|
211
|
+
warned.add(key);
|
|
212
|
+
console.warn(
|
|
213
|
+
`netjes: <Script strategy="beforeInteractive"> "${key}" has ${found.join(', ')}. A beforeInteractive script runs ` +
|
|
214
|
+
"in order, before the page's code, whatever async and defer say, and type=\"module\" makes inline code wait until " +
|
|
215
|
+
'the whole document is parsed. Leave them out, or use strategy="afterInteractive".',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A script loaded by strategy, like `next/script`: `afterInteractive` and `lazyOnload` do not block the page. Scripts
|
|
221
|
+
* are added to the document once, whichever component renders them first, except inline `beforeInteractive` code, which
|
|
222
|
+
* the server writes wherever a component renders it. Only `beforeInteractive` renders anything where it stands, and
|
|
223
|
+
* only into a server-rendered document: the inline script, or an inert entry for an external one, and a marker in the
|
|
224
|
+
* head.
|
|
63
225
|
*/
|
|
64
226
|
export function Script(given: ScriptProps) {
|
|
65
227
|
const pageNonce = useNonce();
|
|
66
228
|
const props = given.nonce || !pageNonce ? given : { ...given, nonce: pageNonce };
|
|
67
|
-
const { src, id, strategy = 'afterInteractive', onLoad, onError, nonce, crossOrigin, integrity } = props;
|
|
229
|
+
const { src, id, strategy = 'afterInteractive', onLoad, onError, nonce, crossOrigin, integrity, children } = props;
|
|
68
230
|
const callbacks = useRef({ onLoad, onError });
|
|
69
231
|
callbacks.current = { onLoad, onError };
|
|
70
232
|
const key = id ?? src;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
233
|
+
// True on the server and while this component hydrates, and for as long as it stays mounted after that, so that it
|
|
234
|
+
// keeps the markup it hydrated. False when it mounts in the browser.
|
|
235
|
+
const fromServer = useRef(useSyncExternalStore(subscribeToNothing, () => false, () => true)).current;
|
|
236
|
+
const inDocument = strategy === 'beforeInteractive' && fromServer && key !== undefined;
|
|
75
237
|
|
|
76
238
|
useEffect(() => {
|
|
77
|
-
if (!key
|
|
239
|
+
if (!key) return;
|
|
78
240
|
let cancelled = false;
|
|
79
241
|
const attach = () => {
|
|
80
242
|
if (cancelled) return;
|
|
243
|
+
// An entry that streamed in after the client entry took the others over is taken over now.
|
|
244
|
+
if (inDocument && !added.has(key)) void adoptServerScripts(pageNonce);
|
|
81
245
|
const entry = added.get(key) ?? add(key, props);
|
|
82
246
|
const notify = () => {
|
|
83
247
|
if (cancelled) return;
|
|
@@ -99,5 +263,32 @@ export function Script(given: ScriptProps) {
|
|
|
99
263
|
// The script is identified by its key; changing other props does not add it again.
|
|
100
264
|
}, [key, strategy]);
|
|
101
265
|
|
|
102
|
-
return null;
|
|
266
|
+
if (!inDocument) return null;
|
|
267
|
+
warnAboutTiming(key, props);
|
|
268
|
+
const { strategy: _strategy, onLoad: _onLoad, onError: _onError, children: _children, src: _src, id: _id, nonce: _nonce, ...rest } = props;
|
|
269
|
+
// In the head, where the client entry looks for it once it has the payload.
|
|
270
|
+
const marker = <meta name={BEFORE_INTERACTIVE_MARKER} />;
|
|
271
|
+
if (!src) {
|
|
272
|
+
// Written as it is (React would escape text children, and they would not match the server's); escaped as React
|
|
273
|
+
// escapes its own scripts, so the text cannot end the element. A hash-based policy lists the hash of that text.
|
|
274
|
+
return (
|
|
275
|
+
<>
|
|
276
|
+
{marker}
|
|
277
|
+
<script {...rest} id={id} nonce={nonce} data-netjes-script={key} dangerouslySetInnerHTML={{ __html: escapeScriptText(children ?? '') }} />
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
// The id goes to the script the entry becomes, not to the entry.
|
|
282
|
+
const attributes = attributesOf(id ? { ...rest, id } : rest);
|
|
283
|
+
// The browser fetches it while it parses; adoptServerScripts runs it from the entry. A script the browser will not run
|
|
284
|
+
// is not fetched: a consent manager's would reach its host before the visitor agreed.
|
|
285
|
+
if (runs(attributes)) {
|
|
286
|
+
preload(src, { as: 'script', ...(nonce ? { nonce } : {}), ...(integrity ? { integrity } : {}), ...(crossOrigin !== undefined ? { crossOrigin } : {}) });
|
|
287
|
+
}
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
{marker}
|
|
291
|
+
<script type="application/json" nonce={nonce} data-netjes-script={key} dangerouslySetInnerHTML={{ __html: htmlSafeJson({ src, attributes }) }} />
|
|
292
|
+
</>
|
|
293
|
+
);
|
|
103
294
|
}
|