@nifrajs/web 5.5.1 → 5.7.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/CHANGELOG.md +243 -0
- package/README.md +21 -11
- package/dist/boundary.d.ts +67 -7
- package/dist/boundary.d.ts.map +1 -1
- package/dist/boundary.js +6 -4
- package/dist/boundary.js.map +1 -1
- package/dist/build-plan.d.ts +3 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +2 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +9 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +10 -0
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +174 -23
- package/dist/client.js.map +1 -1
- package/dist/csp.d.ts +8 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +11 -2
- package/dist/csp.js.map +1 -1
- package/dist/deferred.d.ts +6 -4
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +10 -6
- package/dist/deferred.js.map +1 -1
- package/dist/dev-feed.d.ts.map +1 -1
- package/dist/dev-feed.js +11 -2
- package/dist/dev-feed.js.map +1 -1
- package/dist/dev-session.d.ts.map +1 -1
- package/dist/dev-session.js +13 -6
- package/dist/dev-session.js.map +1 -1
- package/dist/dev.d.ts +4 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +12 -5
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic-catalog.d.ts +4 -0
- package/dist/diagnostic-catalog.d.ts.map +1 -1
- package/dist/diagnostic-catalog.js +48 -0
- package/dist/diagnostic-catalog.js.map +1 -1
- package/dist/diagnostic.d.ts +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +1 -1
- package/dist/diagnostic.js.map +1 -1
- package/dist/fn.d.ts +5 -3
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +20 -5
- package/dist/fn.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +13 -3
- package/dist/fs.js.map +1 -1
- package/dist/identity.d.ts +22 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +60 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/cdn/cloudflare.d.ts +2 -1
- package/dist/internal/cdn/cloudflare.d.ts.map +1 -1
- package/dist/internal/cdn/cloudflare.js +2 -1
- package/dist/internal/cdn/cloudflare.js.map +1 -1
- package/dist/internal/certified-components.d.ts +1 -1
- package/dist/internal/certified-components.js +1 -1
- package/dist/internal/codegen.d.ts +11 -10
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +73 -10
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/dev-websocket.d.ts +73 -0
- package/dist/internal/dev-websocket.d.ts.map +1 -0
- package/dist/internal/dev-websocket.js +145 -0
- package/dist/internal/dev-websocket.js.map +1 -0
- package/dist/internal/hole-runtime.d.ts +66 -0
- package/dist/internal/hole-runtime.d.ts.map +1 -0
- package/dist/internal/hole-runtime.js +211 -0
- package/dist/internal/hole-runtime.js.map +1 -0
- package/dist/internal/identity-epoch.d.ts +50 -0
- package/dist/internal/identity-epoch.d.ts.map +1 -0
- package/dist/internal/identity-epoch.js +180 -0
- package/dist/internal/identity-epoch.js.map +1 -0
- package/dist/internal/invalidation-contract.d.ts +23 -0
- package/dist/internal/invalidation-contract.d.ts.map +1 -0
- package/dist/internal/invalidation-contract.js +11 -0
- package/dist/internal/invalidation-contract.js.map +1 -0
- package/dist/internal/invalidation.d.ts +62 -0
- package/dist/internal/invalidation.d.ts.map +1 -0
- package/dist/internal/invalidation.js +149 -0
- package/dist/internal/invalidation.js.map +1 -0
- package/dist/internal/island-bindings.d.ts +51 -0
- package/dist/internal/island-bindings.d.ts.map +1 -0
- package/dist/internal/island-bindings.js +147 -0
- package/dist/internal/island-bindings.js.map +1 -0
- package/dist/internal/island-list.d.ts +26 -0
- package/dist/internal/island-list.d.ts.map +1 -0
- package/dist/internal/island-list.js +78 -0
- package/dist/internal/island-list.js.map +1 -0
- package/dist/internal/island-mount.d.ts +4 -0
- package/dist/internal/island-mount.d.ts.map +1 -0
- package/dist/internal/island-mount.js +66 -0
- package/dist/internal/island-mount.js.map +1 -0
- package/dist/internal/island-owner.d.ts +5 -0
- package/dist/internal/island-owner.d.ts.map +1 -0
- package/dist/internal/island-owner.js +20 -0
- package/dist/internal/island-owner.js.map +1 -0
- package/dist/internal/island-resource.d.ts +30 -0
- package/dist/internal/island-resource.d.ts.map +1 -0
- package/dist/internal/island-resource.js +57 -0
- package/dist/internal/island-resource.js.map +1 -0
- package/dist/internal/island-signals.d.ts +51 -0
- package/dist/internal/island-signals.d.ts.map +1 -0
- package/dist/internal/island-signals.js +220 -0
- package/dist/internal/island-signals.js.map +1 -0
- package/dist/internal/keep-alive-table.d.ts +23 -0
- package/dist/internal/keep-alive-table.d.ts.map +1 -0
- package/dist/internal/keep-alive-table.js +53 -0
- package/dist/internal/keep-alive-table.js.map +1 -0
- package/dist/internal/live-contract.d.ts +101 -0
- package/dist/internal/live-contract.d.ts.map +1 -0
- package/dist/internal/live-contract.js +5 -0
- package/dist/internal/live-contract.js.map +1 -0
- package/dist/internal/live-queries.d.ts +22 -0
- package/dist/internal/live-queries.d.ts.map +1 -0
- package/dist/internal/live-queries.js +54 -0
- package/dist/internal/live-queries.js.map +1 -0
- package/dist/internal/page-events.d.ts +10 -0
- package/dist/internal/page-events.d.ts.map +1 -0
- package/dist/internal/page-events.js +26 -0
- package/dist/internal/page-events.js.map +1 -0
- package/dist/internal/page-execution.d.ts +16 -1
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +432 -31
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts +3 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +28 -5
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/ppr/admission.d.ts +83 -0
- package/dist/internal/ppr/admission.d.ts.map +1 -0
- package/dist/internal/ppr/admission.js +221 -0
- package/dist/internal/ppr/admission.js.map +1 -0
- package/dist/internal/ppr/canary.d.ts +24 -0
- package/dist/internal/ppr/canary.d.ts.map +1 -0
- package/dist/internal/ppr/canary.js +102 -0
- package/dist/internal/ppr/canary.js.map +1 -0
- package/dist/internal/ppr/compile-shell.d.ts +39 -0
- package/dist/internal/ppr/compile-shell.d.ts.map +1 -0
- package/dist/internal/ppr/compile-shell.js +298 -0
- package/dist/internal/ppr/compile-shell.js.map +1 -0
- package/dist/internal/ppr/contract.d.ts +177 -0
- package/dist/internal/ppr/contract.d.ts.map +1 -0
- package/dist/internal/ppr/contract.js +80 -0
- package/dist/internal/ppr/contract.js.map +1 -0
- package/dist/internal/ppr/frames.d.ts +71 -0
- package/dist/internal/ppr/frames.d.ts.map +1 -0
- package/dist/internal/ppr/frames.js +119 -0
- package/dist/internal/ppr/frames.js.map +1 -0
- package/dist/internal/ppr/integration.d.ts +19 -0
- package/dist/internal/ppr/integration.d.ts.map +1 -0
- package/dist/internal/ppr/integration.js +106 -0
- package/dist/internal/ppr/integration.js.map +1 -0
- package/dist/internal/ppr/options.d.ts +96 -0
- package/dist/internal/ppr/options.d.ts.map +1 -0
- package/dist/internal/ppr/options.js +125 -0
- package/dist/internal/ppr/options.js.map +1 -0
- package/dist/internal/ppr/route-rules.d.ts +12 -0
- package/dist/internal/ppr/route-rules.d.ts.map +1 -0
- package/dist/internal/ppr/route-rules.js +53 -0
- package/dist/internal/ppr/route-rules.js.map +1 -0
- package/dist/internal/ppr/runtime.d.ts +29 -0
- package/dist/internal/ppr/runtime.d.ts.map +1 -0
- package/dist/internal/ppr/runtime.js +35 -0
- package/dist/internal/ppr/runtime.js.map +1 -0
- package/dist/internal/ppr/server.d.ts +29 -0
- package/dist/internal/ppr/server.d.ts.map +1 -0
- package/dist/internal/ppr/server.js +1409 -0
- package/dist/internal/ppr/server.js.map +1 -0
- package/dist/internal/ppr/shell-store.d.ts +39 -0
- package/dist/internal/ppr/shell-store.d.ts.map +1 -0
- package/dist/internal/ppr/shell-store.js +106 -0
- package/dist/internal/ppr/shell-store.js.map +1 -0
- package/dist/internal/prerendered-pages.d.ts +1 -1
- package/dist/internal/prerendered-pages.d.ts.map +1 -1
- package/dist/internal/prerendered-pages.js +7 -4
- package/dist/internal/prerendered-pages.js.map +1 -1
- package/dist/internal/present.d.ts +11 -0
- package/dist/internal/present.d.ts.map +1 -0
- package/dist/internal/present.js +13 -0
- package/dist/internal/present.js.map +1 -0
- package/dist/internal/query-digest.d.ts +9 -0
- package/dist/internal/query-digest.d.ts.map +1 -0
- package/dist/internal/query-digest.js +23 -0
- package/dist/internal/query-digest.js.map +1 -0
- package/dist/internal/query-runtime.d.ts +6 -1
- package/dist/internal/query-runtime.d.ts.map +1 -1
- package/dist/internal/query-runtime.js +9 -0
- package/dist/internal/query-runtime.js.map +1 -1
- package/dist/internal/query-seam.d.ts +29 -0
- package/dist/internal/query-seam.d.ts.map +1 -0
- package/dist/internal/query-seam.js +23 -0
- package/dist/internal/query-seam.js.map +1 -0
- package/dist/internal/render-document.d.ts +34 -64
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +82 -64
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-abort.d.ts +8 -0
- package/dist/internal/request-abort.d.ts.map +1 -0
- package/dist/internal/request-abort.js +22 -0
- package/dist/internal/request-abort.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +2 -0
- package/dist/internal/server-boundary.d.ts.map +1 -1
- package/dist/internal/server-boundary.js +8 -1
- package/dist/internal/server-boundary.js.map +1 -1
- package/dist/internal/share-data.d.ts +10 -0
- package/dist/internal/share-data.d.ts.map +1 -0
- package/dist/internal/share-data.js +135 -0
- package/dist/internal/share-data.js.map +1 -0
- package/dist/internal/status-page.d.ts +68 -0
- package/dist/internal/status-page.d.ts.map +1 -0
- package/dist/internal/status-page.js +66 -0
- package/dist/internal/status-page.js.map +1 -0
- package/dist/internal/store-signature.d.ts +15 -0
- package/dist/internal/store-signature.d.ts.map +1 -0
- package/dist/internal/store-signature.js +114 -0
- package/dist/internal/store-signature.js.map +1 -0
- package/dist/internal/vite-plugin-compat.d.ts +2 -2
- package/dist/internal/vite-plugin-compat.js +2 -2
- package/dist/internal/web-app.d.ts +26 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +79 -3
- package/dist/internal/web-app.js.map +1 -1
- package/dist/islands-swap.d.ts +41 -0
- package/dist/islands-swap.d.ts.map +1 -0
- package/dist/islands-swap.js +270 -0
- package/dist/islands-swap.js.map +1 -0
- package/dist/islands.d.ts +86 -24
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +138 -78
- package/dist/islands.js.map +1 -1
- package/dist/isr.d.ts +67 -13
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +247 -99
- package/dist/isr.js.map +1 -1
- package/dist/keep-alive.d.ts +147 -0
- package/dist/keep-alive.d.ts.map +1 -0
- package/dist/keep-alive.js +1102 -0
- package/dist/keep-alive.js.map +1 -0
- package/dist/live/bus.d.ts +70 -0
- package/dist/live/bus.d.ts.map +1 -0
- package/dist/live/bus.js +55 -0
- package/dist/live/bus.js.map +1 -0
- package/dist/live/core.d.ts +25 -0
- package/dist/live/core.d.ts.map +1 -0
- package/dist/live/core.js +29 -0
- package/dist/live/core.js.map +1 -0
- package/dist/live/history.d.ts +38 -0
- package/dist/live/history.d.ts.map +1 -0
- package/dist/live/history.js +104 -0
- package/dist/live/history.js.map +1 -0
- package/dist/live/hub-protocol.d.ts +46 -0
- package/dist/live/hub-protocol.d.ts.map +1 -0
- package/dist/live/hub-protocol.js +108 -0
- package/dist/live/hub-protocol.js.map +1 -0
- package/dist/live/hub.d.ts +185 -0
- package/dist/live/hub.d.ts.map +1 -0
- package/dist/live/hub.js +544 -0
- package/dist/live/hub.js.map +1 -0
- package/dist/live/keyring.d.ts +92 -0
- package/dist/live/keyring.d.ts.map +1 -0
- package/dist/live/keyring.js +265 -0
- package/dist/live/keyring.js.map +1 -0
- package/dist/live/read-capped.d.ts +6 -0
- package/dist/live/read-capped.d.ts.map +1 -0
- package/dist/live/read-capped.js +42 -0
- package/dist/live/read-capped.js.map +1 -0
- package/dist/live/revocations.d.ts +52 -0
- package/dist/live/revocations.d.ts.map +1 -0
- package/dist/live/revocations.js +116 -0
- package/dist/live/revocations.js.map +1 -0
- package/dist/live/settings.d.ts +14 -0
- package/dist/live/settings.d.ts.map +1 -0
- package/dist/live/settings.js +38 -0
- package/dist/live/settings.js.map +1 -0
- package/dist/live-client.d.ts +34 -0
- package/dist/live-client.d.ts.map +1 -0
- package/dist/live-client.js +878 -0
- package/dist/live-client.js.map +1 -0
- package/dist/live-hub.d.ts +130 -0
- package/dist/live-hub.d.ts.map +1 -0
- package/dist/live-hub.js +507 -0
- package/dist/live-hub.js.map +1 -0
- package/dist/live-redis.d.ts +60 -0
- package/dist/live-redis.d.ts.map +1 -0
- package/dist/live-redis.js +212 -0
- package/dist/live-redis.js.map +1 -0
- package/dist/live.d.ts +150 -0
- package/dist/live.d.ts.map +1 -0
- package/dist/live.js +972 -0
- package/dist/live.js.map +1 -0
- package/dist/manifest.d.ts +113 -2
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +109 -5
- package/dist/manifest.js.map +1 -1
- package/dist/navigation.d.ts +9 -3
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +13 -3
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/vite-zone-guard.d.ts +6 -0
- package/dist/plugins/vite-zone-guard.d.ts.map +1 -1
- package/dist/plugins/vite-zone-guard.js +8 -2
- package/dist/plugins/vite-zone-guard.js.map +1 -1
- package/dist/ppr.d.ts +73 -0
- package/dist/ppr.d.ts.map +1 -0
- package/dist/ppr.js +76 -0
- package/dist/ppr.js.map +1 -0
- package/dist/query.d.ts +8 -13
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +112 -18
- package/dist/query.js.map +1 -1
- package/dist/render-seam.d.ts +32 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +7 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/route-types.d.ts.map +1 -1
- package/dist/route-types.js +2 -2
- package/dist/route-types.js.map +1 -1
- package/dist/router.d.ts +72 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +416 -129
- package/dist/router.js.map +1 -1
- package/dist/server-components/build.d.ts.map +1 -1
- package/dist/server-components/build.js +3 -3
- package/dist/server-components/build.js.map +1 -1
- package/dist/server-components/client.d.ts +23 -1
- package/dist/server-components/client.d.ts.map +1 -1
- package/dist/server-components/client.js +92 -5
- package/dist/server-components/client.js.map +1 -1
- package/dist/server-components/definition.d.ts.map +1 -1
- package/dist/server-components/definition.js +2 -1
- package/dist/server-components/definition.js.map +1 -1
- package/dist/server-components/hole.d.ts +8 -0
- package/dist/server-components/hole.d.ts.map +1 -0
- package/dist/server-components/hole.js +20 -0
- package/dist/server-components/hole.js.map +1 -0
- package/dist/server-components/html-policy.d.ts.map +1 -1
- package/dist/server-components/html-policy.js +62 -4
- package/dist/server-components/html-policy.js.map +1 -1
- package/dist/server-components/html-scan.d.ts +17 -0
- package/dist/server-components/html-scan.d.ts.map +1 -0
- package/dist/server-components/html-scan.js +180 -0
- package/dist/server-components/html-scan.js.map +1 -0
- package/dist/server-components/html.d.ts.map +1 -1
- package/dist/server-components/html.js +19 -4
- package/dist/server-components/html.js.map +1 -1
- package/dist/server-components/router-integration.d.ts +4 -1
- package/dist/server-components/router-integration.d.ts.map +1 -1
- package/dist/server-components/router-integration.js +23 -2
- package/dist/server-components/router-integration.js.map +1 -1
- package/dist/server-components/runtime.d.ts +6 -1
- package/dist/server-components/runtime.d.ts.map +1 -1
- package/dist/server-components/runtime.js +88 -29
- package/dist/server-components/runtime.js.map +1 -1
- package/dist/server-components/state.d.ts +3 -1
- package/dist/server-components/state.d.ts.map +1 -1
- package/dist/server-components/state.js +50 -38
- package/dist/server-components/state.js.map +1 -1
- package/dist/server-components/types.d.ts +9 -0
- package/dist/server-components/types.d.ts.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +26 -9
- package/dist/server-components/vite.js.map +1 -1
- package/dist/vite.d.ts +5 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +58 -4
- package/dist/vite.js.map +1 -1
- package/package.json +48 -8
- package/src/boundary.ts +77 -5
- package/src/build-plan.ts +3 -0
- package/src/build-vite.ts +2 -0
- package/src/build.ts +20 -1
- package/src/client.ts +183 -23
- package/src/csp.ts +16 -2
- package/src/deferred.ts +11 -5
- package/src/dev-feed.ts +11 -2
- package/src/dev-session.ts +22 -11
- package/src/dev.ts +29 -8
- package/src/diagnostic-catalog.ts +53 -0
- package/src/diagnostic.ts +1 -0
- package/src/fn.ts +24 -7
- package/src/fs.ts +22 -8
- package/src/identity.ts +61 -0
- package/src/index.ts +20 -4
- package/src/internal/cdn/cloudflare.ts +2 -1
- package/src/internal/certified-components.ts +1 -1
- package/src/internal/codegen.ts +95 -9
- package/src/internal/dev-websocket.ts +230 -0
- package/src/internal/hole-runtime.ts +286 -0
- package/src/internal/identity-epoch.ts +239 -0
- package/src/internal/invalidation-contract.ts +35 -0
- package/src/internal/invalidation.ts +213 -0
- package/src/internal/island-bindings.ts +191 -0
- package/src/internal/island-list.ts +92 -0
- package/src/internal/island-mount.ts +68 -0
- package/src/internal/island-owner.ts +20 -0
- package/src/internal/island-resource.ts +76 -0
- package/src/internal/island-signals.ts +249 -0
- package/src/internal/keep-alive-table.ts +72 -0
- package/src/internal/live-contract.ts +111 -0
- package/src/internal/live-queries.ts +60 -0
- package/src/internal/page-events.ts +27 -0
- package/src/internal/page-execution.ts +605 -45
- package/src/internal/parity.ts +35 -6
- package/src/internal/ppr/admission.ts +300 -0
- package/src/internal/ppr/canary.ts +122 -0
- package/src/internal/ppr/compile-shell.ts +379 -0
- package/src/internal/ppr/contract.ts +265 -0
- package/src/internal/ppr/frames.ts +170 -0
- package/src/internal/ppr/integration.ts +137 -0
- package/src/internal/ppr/options.ts +221 -0
- package/src/internal/ppr/route-rules.ts +77 -0
- package/src/internal/ppr/runtime.ts +39 -0
- package/src/internal/ppr/server.ts +1766 -0
- package/src/internal/ppr/shell-store.ts +152 -0
- package/src/internal/prerendered-pages.ts +7 -4
- package/src/internal/present.ts +20 -0
- package/src/internal/query-digest.ts +34 -0
- package/src/internal/query-runtime.ts +14 -1
- package/src/internal/query-seam.ts +48 -0
- package/src/internal/render-document.ts +158 -113
- package/src/internal/request-abort.ts +26 -0
- package/src/internal/server-boundary.ts +10 -1
- package/src/internal/share-data.ts +136 -0
- package/src/internal/status-page.ts +95 -0
- package/src/internal/store-signature.ts +149 -0
- package/src/internal/vite-plugin-compat.ts +2 -2
- package/src/internal/web-app.ts +123 -3
- package/src/islands-swap.ts +283 -0
- package/src/islands.ts +169 -72
- package/src/isr.ts +348 -106
- package/src/keep-alive.ts +1354 -0
- package/src/live/bus.ts +104 -0
- package/src/live/core.ts +52 -0
- package/src/live/history.ts +109 -0
- package/src/live/hub-protocol.ts +133 -0
- package/src/live/hub.ts +620 -0
- package/src/live/keyring.ts +384 -0
- package/src/live/read-capped.ts +36 -0
- package/src/live/revocations.ts +155 -0
- package/src/live/settings.ts +46 -0
- package/src/live-client.ts +1011 -0
- package/src/live-hub.ts +658 -0
- package/src/live-redis.ts +284 -0
- package/src/live.ts +1257 -0
- package/src/manifest.ts +274 -6
- package/src/navigation.ts +14 -2
- package/src/plugins/vite-zone-guard.ts +15 -2
- package/src/ppr.ts +153 -0
- package/src/query.ts +147 -20
- package/src/render-seam.ts +37 -0
- package/src/route-types.ts +3 -1
- package/src/router.ts +534 -132
- package/src/server-components/build.ts +9 -3
- package/src/server-components/client.ts +89 -5
- package/src/server-components/definition.ts +2 -1
- package/src/server-components/hole.ts +23 -0
- package/src/server-components/html-policy.ts +63 -4
- package/src/server-components/html-scan.ts +159 -0
- package/src/server-components/html.ts +18 -4
- package/src/server-components/router-integration.ts +21 -2
- package/src/server-components/runtime.ts +114 -31
- package/src/server-components/state.ts +66 -44
- package/src/server-components/types.ts +16 -0
- package/src/server-components/vite.ts +33 -14
- package/src/vite.ts +75 -6
- package/dist/nano.d.ts +0 -125
- package/dist/nano.d.ts.map +0 -1
- package/dist/nano.js +0 -190
- package/dist/nano.js.map +0 -1
- package/src/nano.ts +0 -246
package/src/router.ts
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
import { decodeRouteParams } from "@nifrajs/core/pattern"
|
|
8
8
|
import { Router as CoreRouter } from "@nifrajs/core/router"
|
|
9
9
|
import type { StandardSchemaV1 } from "@nifrajs/core/server"
|
|
10
|
-
import type { BoundaryDescriptor, BoundaryStates } from "./boundary.ts"
|
|
10
|
+
import type { BoundaryDescriptor, BoundaryState, BoundaryStates } from "./boundary.ts"
|
|
11
11
|
import { parseNdjsonData } from "./deferred.ts"
|
|
12
|
+
import { epochOf, IDENTITY_CHANNEL, onIdentityChange } from "./identity.ts"
|
|
13
|
+
import { present } from "./internal/present.ts"
|
|
12
14
|
import { parsePageData } from "./internal/revive-dates.ts"
|
|
13
15
|
import { appUrl, requestPattern } from "./internal/route-path.ts"
|
|
16
|
+
import { shareData } from "./internal/share-data.ts"
|
|
14
17
|
import type { ClientActionResult, ClientRequestBody, ClientRouteHooks } from "./manifest.ts"
|
|
15
18
|
import type { MatchChain } from "./render-seam.ts"
|
|
16
19
|
import { isClientOnlySearchChange } from "./search.ts"
|
|
@@ -21,6 +24,12 @@ import { isClientOnlySearchChange } from "./search.ts"
|
|
|
21
24
|
*/
|
|
22
25
|
export const DATA_HEADER = "x-nifra-data"
|
|
23
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Sent beside {@link DATA_HEADER} by a prefetch. A partially prerendered route answers it with its
|
|
29
|
+
* shell data and starts no hole: nothing could cancel that work if the link is never followed.
|
|
30
|
+
*/
|
|
31
|
+
export const PREFETCH_HEADER = "x-nifra-prefetch"
|
|
32
|
+
|
|
24
33
|
/**
|
|
25
34
|
* Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
|
|
26
35
|
* The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
|
|
@@ -84,6 +93,20 @@ export const RETAIN_HEADER = "x-nifra-retain"
|
|
|
84
93
|
*/
|
|
85
94
|
export const NAV_FROM_HEADER = "x-nifra-from"
|
|
86
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Sent on a data request that reloads the route instance on screen: the holes, comma separated, whose
|
|
98
|
+
* hosts the partially prerendered page holds. Each one's state then carries its fill (server HTML,
|
|
99
|
+
* plus props when it hydrates) instead of its data, so the client updates the host in place.
|
|
100
|
+
*/
|
|
101
|
+
export const HOLE_FILL_HEADER = "x-nifra-hole-fill"
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Holes of a partially prerendered page the client keeps, comma separated: they are not loaded, and
|
|
105
|
+
* each state says so. Only the live load endpoint sets it, for holes whose keys did not change. A name
|
|
106
|
+
* the route does not declare is refused with a 400.
|
|
107
|
+
*/
|
|
108
|
+
export const KEEP_HOLES_HEADER = "x-nifra-keep-holes"
|
|
109
|
+
|
|
87
110
|
/**
|
|
88
111
|
* The data-mode payload once a chain can carry layout data.
|
|
89
112
|
*
|
|
@@ -105,6 +128,8 @@ export interface RouteDataEnvelope {
|
|
|
105
128
|
/** Terminal status signalled by a loader. Added by the browser transport after reading
|
|
106
129
|
* `X-Nifra-Status`; old servers and static data files simply omit it. */
|
|
107
130
|
readonly status?: number
|
|
131
|
+
/** A live load's handle, carried through to {@link RouterState.live} untouched. */
|
|
132
|
+
readonly live?: unknown
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
/** Recognise the envelope without mistaking a plain loader object that happens to have a `data` key. */
|
|
@@ -136,7 +161,8 @@ export interface RouterState {
|
|
|
136
161
|
readonly serverComponents?: import("./server-components/types.ts").ComponentState | undefined
|
|
137
162
|
readonly routeId: string
|
|
138
163
|
readonly params: Record<string, string>
|
|
139
|
-
/** The current URL path (used to revalidate the active loader after an action).
|
|
164
|
+
/** The current URL path (used to revalidate the active loader after an action). While
|
|
165
|
+
* {@link intercepted} is set it is the target shown in the boundary, not the page's own. */
|
|
140
166
|
readonly path: string
|
|
141
167
|
readonly data: unknown
|
|
142
168
|
/** Per-layout loader data, aligned with the matched chain's leading layout prefix. Absent when no
|
|
@@ -154,6 +180,33 @@ export interface RouterState {
|
|
|
154
180
|
readonly pendingPath?: string | undefined
|
|
155
181
|
/** The in-flight submit (set during a `submit`, cleared when it settles) - for optimistic UI. */
|
|
156
182
|
readonly submission?: Submission
|
|
183
|
+
/** Set while an intercept boundary shows a navigation's target over the page: the boundary holding
|
|
184
|
+
* it, and the path the page underneath was loaded from. The page revalidates under `pagePath`, the
|
|
185
|
+
* boundary under `path`. */
|
|
186
|
+
readonly intercepted?: Intercepted
|
|
187
|
+
/** Opaque: which live load the page data on screen came from (`@nifrajs/web/live-client`). */
|
|
188
|
+
readonly live?: unknown
|
|
189
|
+
/** The page instance on screen, when the app keeps pages alive (`@nifrajs/web/keep-alive`). */
|
|
190
|
+
readonly instance?: string | undefined
|
|
191
|
+
/** Pages kept mounted and hidden, least recently shown first. Absent when none is kept. */
|
|
192
|
+
readonly kept?: readonly KeptPage[] | undefined
|
|
193
|
+
/** This publish showed a kept page again. */
|
|
194
|
+
readonly restored?: boolean | undefined
|
|
195
|
+
/** A page shown again is reloading its data in place: not a navigation, so `pending` stays false. */
|
|
196
|
+
readonly revalidating?: boolean | undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A page kept mounted while its history entry is not the one on screen. */
|
|
200
|
+
export interface KeptPage {
|
|
201
|
+
readonly instance: string
|
|
202
|
+
/** Its last settled state, which it renders from while hidden. */
|
|
203
|
+
readonly state: RouterState
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Which intercept boundary is open, and the page underneath it. */
|
|
207
|
+
export interface Intercepted {
|
|
208
|
+
readonly boundary: string
|
|
209
|
+
readonly pagePath: string
|
|
157
210
|
}
|
|
158
211
|
|
|
159
212
|
/** A route id paired with its nifra pattern (e.g. `":id"` segments) - the matcher input. */
|
|
@@ -194,8 +247,22 @@ export function createMatcher(
|
|
|
194
247
|
}
|
|
195
248
|
}
|
|
196
249
|
|
|
250
|
+
/**
|
|
251
|
+
* What the router knows about the response text a path's data on screen was read from. The router's
|
|
252
|
+
* own transport resolves a reload answering that same text to this object itself, and the router keeps
|
|
253
|
+
* the data on screen without parsing it; for any other plain JSON response it records the text in
|
|
254
|
+
* `text`. A response with deferred parts sets neither, and a custom transport may ignore it.
|
|
255
|
+
*/
|
|
256
|
+
export interface DataText {
|
|
257
|
+
readonly previous: string | undefined
|
|
258
|
+
text?: string
|
|
259
|
+
}
|
|
260
|
+
|
|
197
261
|
/** How a router fetches a route's loader data on navigation. `signal` aborts a superseded fetch
|
|
198
|
-
* (and its deferred stream).
|
|
262
|
+
* (and its deferred stream). `purpose` is `"prefetch"` when no navigation is waiting on the result.
|
|
263
|
+
* `hosts` names the holes of the page on screen whose hosts reload in place
|
|
264
|
+
* ({@link HOLE_FILL_HEADER}). `read` is the {@link DataText} of the data on screen, which a custom
|
|
265
|
+
* transport may ignore. */
|
|
199
266
|
export type FetchRouteData = (
|
|
200
267
|
path: string,
|
|
201
268
|
match: RouteMatch,
|
|
@@ -204,6 +271,9 @@ export type FetchRouteData = (
|
|
|
204
271
|
readonly from: string
|
|
205
272
|
readonly retain: readonly number[]
|
|
206
273
|
},
|
|
274
|
+
purpose?: "prefetch",
|
|
275
|
+
hosts?: readonly string[],
|
|
276
|
+
read?: DataText,
|
|
207
277
|
) => Promise<unknown>
|
|
208
278
|
|
|
209
279
|
/** Per-submit options. `revalidate: false` opts out of the post-action loader re-fetch. */
|
|
@@ -265,7 +335,8 @@ export interface ClientRouter {
|
|
|
265
335
|
* the action already returned everything that changed, saving the extra round-trip). A redirect
|
|
266
336
|
* always loads its target regardless. Rejects on failure (caller falls back); once the server has
|
|
267
337
|
* answered, at any status, the rejection's `redirectTo` names the page to load instead of posting
|
|
268
|
-
* again.
|
|
338
|
+
* again. A revalidation answered with a terminal status renders that status route in the page's
|
|
339
|
+
* place.
|
|
269
340
|
*/
|
|
270
341
|
submit: (
|
|
271
342
|
action: string,
|
|
@@ -280,6 +351,8 @@ export interface ClientRouter {
|
|
|
280
351
|
* refreshes immediately - refetched + republished - whenever it's in scope (an explicit list that
|
|
281
352
|
* includes it, or an invalidate-all); other stale entries refetch lazily when next read (a
|
|
282
353
|
* fetcher, or the next navigation/access). Rejects if the active refetch fails (like `navigate`).
|
|
354
|
+
* A refetch answered with a terminal status renders that status route in the page's place; one
|
|
355
|
+
* whose status page renders inside layouts rejects, so the caller can load the page as a document.
|
|
283
356
|
* The keyed substrate for targeted revalidation (the `X-Nifra-Revalidate` header) and fetchers.
|
|
284
357
|
*/
|
|
285
358
|
invalidate: (paths?: readonly string[]) => Promise<void>
|
|
@@ -354,10 +427,19 @@ export interface MountRouterOptions {
|
|
|
354
427
|
/** Read a nifra data response: a deferred loader/action streams NDJSON (parse line 1 + settle
|
|
355
428
|
* `<Await>` markers as resolution lines arrive); a non-deferred one returns a single JSON. Shared by
|
|
356
429
|
* navigation fetches and action submits - both transports are identical. */
|
|
357
|
-
const readResponseData = (
|
|
430
|
+
const readResponseData = (
|
|
431
|
+
res: Response,
|
|
432
|
+
signal?: AbortSignal,
|
|
433
|
+
read?: DataText,
|
|
434
|
+
): Promise<unknown> =>
|
|
358
435
|
(res.headers.get("content-type") ?? "").includes("application/x-ndjson") && res.body !== null
|
|
359
436
|
? parseNdjsonData(res.body, signal)
|
|
360
|
-
: res.text().then(
|
|
437
|
+
: res.text().then((text) => {
|
|
438
|
+
if (read === undefined) return parsePageData(text)
|
|
439
|
+
if (text === read.previous) return read
|
|
440
|
+
read.text = text
|
|
441
|
+
return parsePageData(text)
|
|
442
|
+
})
|
|
361
443
|
|
|
362
444
|
// Index scan, not `/\/+$/` - the unanchored-start trailing-run replace backtracks quadratically and
|
|
363
445
|
// the pathname is caller-controlled.
|
|
@@ -418,7 +500,36 @@ const targetUrl = (to: string): URL | undefined => {
|
|
|
418
500
|
const redirected = (to: string, cause?: unknown): Error =>
|
|
419
501
|
Object.assign(new Error(`[nifra/web] redirected to ${to}`, { cause }), { redirectTo: to })
|
|
420
502
|
|
|
421
|
-
|
|
503
|
+
/**
|
|
504
|
+
* A route data response as `fetchData` resolves it: a loader's in-app redirect rejects with its
|
|
505
|
+
* target, a terminal status becomes an envelope carrying it, and any other failure rejects.
|
|
506
|
+
*/
|
|
507
|
+
export const routeDataOf = async (
|
|
508
|
+
res: Response,
|
|
509
|
+
path: string,
|
|
510
|
+
signal?: AbortSignal,
|
|
511
|
+
read?: DataText,
|
|
512
|
+
): Promise<unknown> => {
|
|
513
|
+
const redirectTo = res.headers.get(REDIRECT_HEADER)
|
|
514
|
+
if (redirectTo !== null) throw redirected(redirectTo)
|
|
515
|
+
const status = Number(res.headers.get(STATUS_HEADER))
|
|
516
|
+
if (Number.isInteger(status) && status >= 400 && status <= 599) {
|
|
517
|
+
return { v: 1, data: null, status } satisfies RouteDataEnvelope
|
|
518
|
+
}
|
|
519
|
+
if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
|
|
520
|
+
return readResponseData(res, signal, read)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The router's own transport, which a `fetchData` override may layer over. */
|
|
524
|
+
export const fetchRouteData: FetchRouteData = async (
|
|
525
|
+
path,
|
|
526
|
+
_match,
|
|
527
|
+
signal,
|
|
528
|
+
navigation,
|
|
529
|
+
purpose,
|
|
530
|
+
hosts,
|
|
531
|
+
read,
|
|
532
|
+
) => {
|
|
422
533
|
// SSG fast path: if this path was prerendered, its loader data is a static file - fetch that (no
|
|
423
534
|
// worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
|
|
424
535
|
// or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
|
|
@@ -432,23 +543,22 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
|
|
|
432
543
|
const pathname = hash === -1 ? path : path.slice(0, hash)
|
|
433
544
|
if (prerenderedSetOf(prerendered).has(pathname)) {
|
|
434
545
|
const staticRes = await fetch(appUrl(dataUrlFor(pathname)), { signal: signal ?? null })
|
|
435
|
-
if (staticRes.ok) return readResponseData(staticRes, signal)
|
|
546
|
+
if (staticRes.ok) return readResponseData(staticRes, signal, read)
|
|
436
547
|
}
|
|
437
548
|
}
|
|
438
549
|
const headers = new Headers({ [DATA_HEADER]: "1" })
|
|
550
|
+
if (purpose === "prefetch") headers.set(PREFETCH_HEADER, "1")
|
|
551
|
+
if (hosts !== undefined && hosts.length > 0) headers.set(HOLE_FILL_HEADER, hosts.join(","))
|
|
439
552
|
if (navigation !== undefined && navigation.retain.length > 0) {
|
|
440
553
|
headers.set(RETAIN_HEADER, navigation.retain.join(","))
|
|
441
554
|
headers.set(NAV_FROM_HEADER, navigation.from)
|
|
442
555
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
}
|
|
450
|
-
if (!res.ok) throw new Error(`[nifra/web] navigation data fetch failed (${res.status}): ${path}`)
|
|
451
|
-
return readResponseData(res, signal)
|
|
556
|
+
return routeDataOf(
|
|
557
|
+
await fetch(appUrl(path), { headers, signal: signal ?? null }),
|
|
558
|
+
path,
|
|
559
|
+
signal,
|
|
560
|
+
read,
|
|
561
|
+
)
|
|
452
562
|
}
|
|
453
563
|
|
|
454
564
|
/**
|
|
@@ -474,9 +584,9 @@ const isClientActionResult = (value: unknown): value is ClientActionResult =>
|
|
|
474
584
|
|
|
475
585
|
export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
476
586
|
const match = createMatcher(options.patterns)
|
|
477
|
-
const fetchData = options.fetchData ??
|
|
587
|
+
const fetchData = options.fetchData ?? fetchRouteData
|
|
478
588
|
// Start a handed-over list's fetch now, so the first navigation rarely waits for it.
|
|
479
|
-
if (fetchData ===
|
|
589
|
+
if (fetchData === fetchRouteData) prerenderedOf()
|
|
480
590
|
// routeId → the route's client-only search keys. Read by reference (the generated entry keeps writing
|
|
481
591
|
// to it as routes load), so a same-route nav always sees the current route's keys.
|
|
482
592
|
const searchClientKeys = options.searchClientKeys ?? {}
|
|
@@ -535,31 +645,96 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
535
645
|
readonly layoutData?: readonly unknown[] | undefined
|
|
536
646
|
readonly terminalRouteId?: string | undefined
|
|
537
647
|
readonly boundaries?: BoundaryStates
|
|
648
|
+
readonly live?: unknown
|
|
649
|
+
/** The response text this was read from, when it was plain JSON. */
|
|
650
|
+
readonly text?: string | undefined
|
|
538
651
|
}
|
|
539
652
|
const dataFields = (value: LoadedRouteData) => ({
|
|
540
653
|
data: value.data,
|
|
541
654
|
layoutData: value.layoutData,
|
|
542
655
|
serverComponents: value.serverComponents,
|
|
656
|
+
live: value.live,
|
|
543
657
|
...(value.boundaries === undefined ? {} : { boundaries: value.boundaries }),
|
|
544
658
|
})
|
|
659
|
+
// The path the page on screen was loaded from. While a boundary holds an intercepted target, `path`
|
|
660
|
+
// is that target's URL, so anything that reloads or caches the page must read this instead.
|
|
661
|
+
const pagePathOf = (current: RouterState): string => current.intercepted?.pagePath ?? current.path
|
|
662
|
+
// `boundaries` with `name` holding a target's outcome. The URL names one target, so every other
|
|
663
|
+
// intercept boundary goes back to unresolved. A target that answered with a status, or failed, is
|
|
664
|
+
// an error state: its data never reaches the boundary.
|
|
665
|
+
const holdTarget = (
|
|
666
|
+
boundaries: BoundaryStates | undefined,
|
|
667
|
+
descriptor: Omit<BoundaryDescriptor, "hasLoad">,
|
|
668
|
+
outcome: LoadedRouteData | undefined,
|
|
669
|
+
): BoundaryStates => {
|
|
670
|
+
const next: Record<string, BoundaryState> = {}
|
|
671
|
+
for (const [name, boundary] of Object.entries(boundaries ?? {})) {
|
|
672
|
+
next[name] =
|
|
673
|
+
typeof boundary.mode === "string" || name === descriptor.name
|
|
674
|
+
? boundary
|
|
675
|
+
: {
|
|
676
|
+
name: boundary.name,
|
|
677
|
+
mode: boundary.mode,
|
|
678
|
+
status: "unresolved",
|
|
679
|
+
...(boundary.errorId === undefined ? {} : { errorId: boundary.errorId }),
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const base = {
|
|
683
|
+
name: descriptor.name,
|
|
684
|
+
mode: descriptor.mode,
|
|
685
|
+
...(descriptor.errorId === undefined ? {} : { errorId: descriptor.errorId }),
|
|
686
|
+
}
|
|
687
|
+
next[descriptor.name] =
|
|
688
|
+
outcome === undefined || outcome.terminalRouteId !== undefined
|
|
689
|
+
? { ...base, status: "error", error: { name: "Error", message: "Boundary failed" } }
|
|
690
|
+
: { ...base, status: "ready", data: outcome.data, live: outcome.live }
|
|
691
|
+
return next
|
|
692
|
+
}
|
|
693
|
+
// `boundaries` with `name`'s action result set (or cleared, for `undefined`). Only a boundary showing
|
|
694
|
+
// its target's data carries one.
|
|
695
|
+
const withActionData = (
|
|
696
|
+
boundaries: BoundaryStates | undefined,
|
|
697
|
+
name: string,
|
|
698
|
+
actionData: unknown,
|
|
699
|
+
): BoundaryStates | undefined => {
|
|
700
|
+
const held = boundaries?.[name]
|
|
701
|
+
if (held === undefined || held.status !== "ready") return boundaries
|
|
702
|
+
const { actionData: _previous, ...rest } = held
|
|
703
|
+
return { ...boundaries, [name]: actionData === undefined ? rest : { ...rest, actionData } }
|
|
704
|
+
}
|
|
545
705
|
const loadRouteData = async (
|
|
546
706
|
path: string,
|
|
547
707
|
match: { routeId: string; params: Record<string, string> },
|
|
548
708
|
signal?: AbortSignal,
|
|
549
709
|
// Offer the server the layout data on screen: it re-runs whichever of those loaders it must.
|
|
550
710
|
keepLayouts?: boolean,
|
|
711
|
+
purpose?: "prefetch",
|
|
712
|
+
hosts?: readonly string[],
|
|
713
|
+
// What this path shows, when this is a reload of it: the same response text again is that, unparsed.
|
|
714
|
+
shown?: LoadedRouteData,
|
|
551
715
|
): Promise<LoadedRouteData> => {
|
|
552
716
|
const retainFrom = keepLayouts
|
|
553
|
-
? state.layoutData && { path: state
|
|
717
|
+
? state.layoutData && { path: pagePathOf(state), layoutData: state.layoutData }
|
|
554
718
|
: undefined
|
|
555
719
|
const retain = retainFrom?.layoutData.map((_, index) => index) ?? []
|
|
720
|
+
const hooks = routeHooks[match.routeId]
|
|
721
|
+
// A client loader's output is not what the text says, and an opted-out route takes fresh data.
|
|
722
|
+
const read: DataText = {
|
|
723
|
+
previous:
|
|
724
|
+
hooks?.clientLoader === undefined && hooks?.shareData !== false ? shown?.text : undefined,
|
|
725
|
+
}
|
|
556
726
|
const payload = await fetchData(
|
|
557
727
|
path,
|
|
558
728
|
match,
|
|
559
729
|
signal,
|
|
560
730
|
retainFrom === undefined ? undefined : { from: retainFrom.path, retain },
|
|
731
|
+
purpose,
|
|
732
|
+
hosts,
|
|
733
|
+
read,
|
|
561
734
|
)
|
|
562
|
-
if (
|
|
735
|
+
if (shown !== undefined && payload === read) return shown
|
|
736
|
+
const text = read.text
|
|
737
|
+
if (!isRouteDataEnvelope(payload)) return { data: payload, text }
|
|
563
738
|
if (payload.status !== undefined) {
|
|
564
739
|
const terminalRouteId = options.statusRoutes?.[payload.status] ?? options.statusRoutes?.[404]
|
|
565
740
|
if (terminalRouteId === undefined) {
|
|
@@ -584,7 +759,31 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
584
759
|
data: payload.data,
|
|
585
760
|
layoutData,
|
|
586
761
|
serverComponents: payload.serverComponents,
|
|
762
|
+
live: payload.live,
|
|
587
763
|
...(payload.boundaries !== undefined ? { boundaries: payload.boundaries } : {}),
|
|
764
|
+
text,
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// A reload with every part equal to what its path shows replaced by that part, so what the reload
|
|
768
|
+
// did not change keeps its identity on screen (`shareData`), unless the route exports
|
|
769
|
+
// `shareData = false`.
|
|
770
|
+
const shareShown = (
|
|
771
|
+
routeId: string,
|
|
772
|
+
shown: LoadedRouteData,
|
|
773
|
+
loaded: LoadedRouteData,
|
|
774
|
+
): LoadedRouteData => {
|
|
775
|
+
if (loaded.terminalRouteId !== undefined || routeHooks[routeId]?.shareData === false)
|
|
776
|
+
return loaded
|
|
777
|
+
// Parsed JSON is a tree; a client loader/custom transport can return cyclic or aliased data.
|
|
778
|
+
const plainJson = loaded.text !== undefined && routeHooks[routeId]?.clientLoader === undefined
|
|
779
|
+
return {
|
|
780
|
+
...loaded,
|
|
781
|
+
data: shareData(shown.data, loaded.data, plainJson),
|
|
782
|
+
layoutData: shareData(shown.layoutData, loaded.layoutData, plainJson),
|
|
783
|
+
serverComponents: shareData(shown.serverComponents, loaded.serverComponents, plainJson),
|
|
784
|
+
...(loaded.boundaries === undefined
|
|
785
|
+
? {}
|
|
786
|
+
: { boundaries: shareData(shown.boundaries, loaded.boundaries, plainJson) }),
|
|
588
787
|
}
|
|
589
788
|
}
|
|
590
789
|
/**
|
|
@@ -638,7 +837,11 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
638
837
|
params: target.params,
|
|
639
838
|
signal,
|
|
640
839
|
body,
|
|
641
|
-
|
|
840
|
+
// An action posted to the target an intercept boundary holds belongs to that target.
|
|
841
|
+
serverLoader: async () =>
|
|
842
|
+
state.intercepted !== undefined && pathnameOf(action) === pathnameOf(state.path)
|
|
843
|
+
? state.boundaries?.[state.intercepted.boundary]?.data
|
|
844
|
+
: state.data,
|
|
642
845
|
})
|
|
643
846
|
if (result === undefined) return { body }
|
|
644
847
|
if (!isClientActionResult(result)) {
|
|
@@ -668,18 +871,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
668
871
|
initial,
|
|
669
872
|
)
|
|
670
873
|
if (mine !== generation) return
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
} else {
|
|
675
|
-
state = {
|
|
676
|
-
...state,
|
|
677
|
-
...dataFields(loaded),
|
|
678
|
-
}
|
|
679
|
-
}
|
|
874
|
+
// No terminal status can arrive here: the server already rendered this route (a status page
|
|
875
|
+
// included) and its data is the only server result the client loader sees.
|
|
876
|
+
state = { ...state, ...dataFields(loaded) }
|
|
680
877
|
emit()
|
|
681
878
|
}
|
|
682
879
|
let state = options.initial
|
|
880
|
+
// The page as last published from a load, with the response text it was read from. Every publish of
|
|
881
|
+
// page data replaces it, and a reload reuses it only for the same text, which says the same.
|
|
882
|
+
let shownPage: LoadedRouteData | undefined
|
|
683
883
|
const listeners = new Set<() => void>()
|
|
684
884
|
let generation = 0
|
|
685
885
|
// Snapshot listeners to defend against accidental un/subscribe during notification. The adapters
|
|
@@ -694,12 +894,33 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
694
894
|
// fresh: a link warmed as it scrolled into view can be clicked minutes later.
|
|
695
895
|
const MAX_PREFETCH = 10
|
|
696
896
|
const PREFETCH_MAX_AGE_MS = 30_000
|
|
697
|
-
|
|
698
|
-
const
|
|
897
|
+
// path -> [data, when it arrived, the identity epoch it was loaded under]
|
|
898
|
+
const prefetched = new Map<string, readonly [LoadedRouteData, number, string]>()
|
|
899
|
+
// path -> the token of the prefetch loading it; a prefetch stores what it read only while its
|
|
900
|
+
// token is still there, so dropping the token discards a load that started before the drop.
|
|
901
|
+
const inflight = new Map<string, object>()
|
|
902
|
+
// Read without the DOM types: this module also builds in tsconfigs that leave the DOM lib out.
|
|
903
|
+
const readEpoch = (): string => {
|
|
904
|
+
const cookie: unknown = Reflect.get(Reflect.get(globalThis, "document") ?? {}, "cookie")
|
|
905
|
+
return typeof cookie === "string" ? epochOf(cookie) : ""
|
|
906
|
+
}
|
|
699
907
|
const freshPrefetch = (path: string): LoadedRouteData | undefined => {
|
|
700
908
|
const held = prefetched.get(path)
|
|
701
|
-
return held !== undefined &&
|
|
909
|
+
return held !== undefined &&
|
|
910
|
+
Date.now() - held[1] < PREFETCH_MAX_AGE_MS &&
|
|
911
|
+
held[2] === readEpoch()
|
|
912
|
+
? held[0]
|
|
913
|
+
: undefined
|
|
702
914
|
}
|
|
915
|
+
// Warmed data was loaded before a mutation or for the previous visitor, so either one drops all of
|
|
916
|
+
// it, along with what a prefetch still in flight read.
|
|
917
|
+
const dropPrefetched = (): void => {
|
|
918
|
+
inflight.clear()
|
|
919
|
+
prefetched.clear()
|
|
920
|
+
}
|
|
921
|
+
onIdentityChange(dropPrefetched)
|
|
922
|
+
if (typeof BroadcastChannel !== "undefined")
|
|
923
|
+
new BroadcastChannel(IDENTITY_CHANNEL).addEventListener("message", dropPrefetched)
|
|
703
924
|
// Keyed data cache (path → latest loader data + freshness). Written on every published data
|
|
704
925
|
// (navigate/submit), read by `invalidate` (+ targeted revalidation and fetchers in later F16
|
|
705
926
|
// increments). Bounded - evict the oldest-inserted past the cap (route data is small; this just
|
|
@@ -737,6 +958,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
737
958
|
// `X-Nifra-Revalidate` handling in `submit`.
|
|
738
959
|
const markStale = (paths: readonly string[]): void => {
|
|
739
960
|
for (const p of paths) {
|
|
961
|
+
prefetched.delete(p)
|
|
962
|
+
inflight.delete(p)
|
|
740
963
|
const entry = cache.get(p)
|
|
741
964
|
if (entry !== undefined) {
|
|
742
965
|
cache.set(p, {
|
|
@@ -773,6 +996,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
773
996
|
return target === null ? undefined : [path, target]
|
|
774
997
|
}
|
|
775
998
|
|
|
999
|
+
// Load the status route a terminal answer for `at` renders. One inside layouts needs those layouts'
|
|
1000
|
+
// loaders, which only a document request runs, so the caller loads `at` as a document instead.
|
|
1001
|
+
const loadTerminal = async (routeId: string, at: string): Promise<void> => {
|
|
1002
|
+
await loadModule?.(routeId, at)
|
|
1003
|
+
if (options.layoutStatusRoutes?.[routeId] === true) {
|
|
1004
|
+
throw new Error(`[nifra/web] ${routeId} renders inside layouts; loading ${at} as a document`)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
776
1008
|
// Load `path` for the navigation or submit `mine`, following the redirects its loaders answer with
|
|
777
1009
|
// while they stay in the app (20 at most, as a browser allows).
|
|
778
1010
|
const loadFollowing = async (
|
|
@@ -802,14 +1034,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
802
1034
|
() => loadRouteData(at, route, signal, keepLayouts),
|
|
803
1035
|
prefetchedData,
|
|
804
1036
|
)
|
|
805
|
-
if (loaded.terminalRouteId !== undefined)
|
|
806
|
-
await loadModule?.(loaded.terminalRouteId, at)
|
|
807
|
-
if (options.layoutStatusRoutes?.[loaded.terminalRouteId] === true) {
|
|
808
|
-
throw new Error(
|
|
809
|
-
`[nifra/web] ${loaded.terminalRouteId} renders inside layouts; loading ${at} as a document`,
|
|
810
|
-
)
|
|
811
|
-
}
|
|
812
|
-
}
|
|
1037
|
+
if (loaded.terminalRouteId !== undefined) await loadTerminal(loaded.terminalRouteId, at)
|
|
813
1038
|
return [at, route, loaded]
|
|
814
1039
|
} catch (error) {
|
|
815
1040
|
const next = hops < 20 ? appRedirect(redirectOf(error)) : undefined
|
|
@@ -819,9 +1044,113 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
819
1044
|
}
|
|
820
1045
|
}
|
|
821
1046
|
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
|
|
1047
|
+
// Reload what is on screen: the page under its own path when `page`, and, while an intercept boundary
|
|
1048
|
+
// holds a target, that target under its URL when `target`. The part not reloaded is kept as shown.
|
|
1049
|
+
// Returns the fields to publish, both parts together. `route` is set when the page's reload names
|
|
1050
|
+
// another route - the status route it answered with, or, on a status page, its URL's own route
|
|
1051
|
+
// answering again - which then replaces the page and any boundary over it, so data is only ever
|
|
1052
|
+
// rendered by the route it was loaded for. A target that fails becomes its
|
|
1053
|
+
// boundary's error state rather than failing the page, as a dynamic boundary's load does. Nothing is
|
|
1054
|
+
// cached once `mine` is superseded: a newer action's reload owns the cache.
|
|
1055
|
+
const reloadActive = async (
|
|
1056
|
+
signal: AbortSignal,
|
|
1057
|
+
page: boolean,
|
|
1058
|
+
target: boolean,
|
|
1059
|
+
mine: number,
|
|
1060
|
+
): Promise<{
|
|
1061
|
+
readonly fields: ReturnType<typeof dataFields>
|
|
1062
|
+
readonly route?: { readonly routeId: string; readonly params: Record<string, string> }
|
|
1063
|
+
}> => {
|
|
1064
|
+
const pagePath = pagePathOf(state)
|
|
1065
|
+
const intercepted = state.intercepted
|
|
1066
|
+
const targetPath = state.path
|
|
1067
|
+
// The route the page's URL matches: the one on screen, except on a status page.
|
|
1068
|
+
const pageRoute = match(pagePath) ?? { routeId: state.routeId, params: state.params }
|
|
1069
|
+
const rematched = pageRoute.routeId !== state.routeId
|
|
1070
|
+
const targetRoute = intercepted !== undefined && target ? match(targetPath) : null
|
|
1071
|
+
// The page's own route shares with what the page shows; a status page answered by its URL's route
|
|
1072
|
+
// has nothing of that route on screen.
|
|
1073
|
+
const shown = rematched ? undefined : (shownPage ?? state)
|
|
1074
|
+
const shownTarget = { data: intercepted && state.boundaries?.[intercepted.boundary]?.data }
|
|
1075
|
+
// A hole on screen that is a host stays one: it reloads as a fill.
|
|
1076
|
+
const hosts = rematched
|
|
1077
|
+
? []
|
|
1078
|
+
: Object.values(state.boundaries ?? {})
|
|
1079
|
+
.filter((held) => held.ppr === true)
|
|
1080
|
+
.map((held) => held.name)
|
|
1081
|
+
const [reloaded, targetOutcome] = await Promise.all([
|
|
1082
|
+
page
|
|
1083
|
+
? (async (): Promise<LoadedRouteData> => {
|
|
1084
|
+
if (rematched) await loadModule?.(pageRoute.routeId, pagePath)
|
|
1085
|
+
const loaded = await applyClientLoader(pagePath, pageRoute, signal, () =>
|
|
1086
|
+
loadRouteData(
|
|
1087
|
+
pagePath,
|
|
1088
|
+
pageRoute,
|
|
1089
|
+
signal,
|
|
1090
|
+
false,
|
|
1091
|
+
undefined,
|
|
1092
|
+
hosts.length === 0 ? undefined : hosts,
|
|
1093
|
+
shown,
|
|
1094
|
+
),
|
|
1095
|
+
)
|
|
1096
|
+
return shown === undefined ? loaded : shareShown(pageRoute.routeId, shown, loaded)
|
|
1097
|
+
})()
|
|
1098
|
+
: undefined,
|
|
1099
|
+
targetRoute === null
|
|
1100
|
+
? undefined
|
|
1101
|
+
: (async (): Promise<LoadedRouteData | undefined> => {
|
|
1102
|
+
try {
|
|
1103
|
+
await loadModule?.(targetRoute.routeId, targetPath)
|
|
1104
|
+
const outcome = shareShown(
|
|
1105
|
+
targetRoute.routeId,
|
|
1106
|
+
shownTarget,
|
|
1107
|
+
await applyClientLoader(targetPath, targetRoute, signal, () =>
|
|
1108
|
+
loadRouteData(targetPath, targetRoute, signal),
|
|
1109
|
+
),
|
|
1110
|
+
)
|
|
1111
|
+
if (mine === generation) cachePut(targetPath, outcome)
|
|
1112
|
+
return outcome
|
|
1113
|
+
} catch {
|
|
1114
|
+
return undefined
|
|
1115
|
+
}
|
|
1116
|
+
})(),
|
|
1117
|
+
])
|
|
1118
|
+
if (reloaded?.terminalRouteId !== undefined)
|
|
1119
|
+
await loadTerminal(reloaded.terminalRouteId, pagePath)
|
|
1120
|
+
if (reloaded !== undefined && mine === generation) {
|
|
1121
|
+
cachePut(pagePath, reloaded)
|
|
1122
|
+
shownPage = reloaded
|
|
1123
|
+
}
|
|
1124
|
+
const fields = dataFields(reloaded ?? state)
|
|
1125
|
+
if (reloaded?.terminalRouteId !== undefined)
|
|
1126
|
+
return { fields, route: { routeId: reloaded.terminalRouteId, params: {} } }
|
|
1127
|
+
if (reloaded !== undefined && rematched) return { fields, route: pageRoute }
|
|
1128
|
+
if (intercepted === undefined) return { fields }
|
|
1129
|
+
const held = state.boundaries?.[intercepted.boundary]
|
|
1130
|
+
if (held === undefined) return { fields }
|
|
1131
|
+
if (targetRoute === null)
|
|
1132
|
+
return {
|
|
1133
|
+
fields: {
|
|
1134
|
+
...fields,
|
|
1135
|
+
boundaries: { ...(fields.boundaries ?? {}), [intercepted.boundary]: held },
|
|
1136
|
+
},
|
|
1137
|
+
}
|
|
1138
|
+
// A reload keeps the result of the last action posted to the target, as the page keeps its own.
|
|
1139
|
+
const boundaries = withActionData(
|
|
1140
|
+
holdTarget(fields.boundaries, held, targetOutcome),
|
|
1141
|
+
intercepted.boundary,
|
|
1142
|
+
held.actionData,
|
|
1143
|
+
)
|
|
1144
|
+
return { fields: { ...fields, ...(boundaries === undefined ? {} : { boundaries }) } }
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Refetch what is on screen + republish, single-flight via the shared `generation`. Shared by
|
|
1148
|
+
// `invalidate` and targeted revalidation: `inScope` says which paths to refresh, and a page or held
|
|
1149
|
+
// target outside it is kept. Rejects if the page's fetch fails (clearing its own `pending` first).
|
|
1150
|
+
const refetchActive = async (inScope?: (path: string) => boolean): Promise<void> => {
|
|
1151
|
+
const page = inScope === undefined || inScope(pagePathOf(state))
|
|
1152
|
+
const target = state.intercepted !== undefined && (inScope === undefined || inScope(state.path))
|
|
1153
|
+
if (!page && !target) return
|
|
825
1154
|
const mine = ++generation
|
|
826
1155
|
navAbort?.abort()
|
|
827
1156
|
const ac = new AbortController()
|
|
@@ -829,17 +1158,21 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
829
1158
|
state = { ...state, pending: true }
|
|
830
1159
|
emit()
|
|
831
1160
|
try {
|
|
832
|
-
const
|
|
833
|
-
const loaded = await applyClientLoader(state.path, active, ac.signal, () =>
|
|
834
|
-
loadRouteData(state.path, active, ac.signal),
|
|
835
|
-
)
|
|
1161
|
+
const { fields, route } = await reloadActive(ac.signal, page, target, mine)
|
|
836
1162
|
if (mine !== generation) return // superseded - drop the stale result
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
1163
|
+
// A terminal answer for the page (the session expired, the record is gone) replaces it, and any
|
|
1164
|
+
// boundary over it, with its status route, as a navigation to this URL would.
|
|
1165
|
+
state =
|
|
1166
|
+
route === undefined
|
|
1167
|
+
? { ...state, ...fields, pending: false }
|
|
1168
|
+
: {
|
|
1169
|
+
routeId: route.routeId,
|
|
1170
|
+
params: route.params,
|
|
1171
|
+
path: state.path,
|
|
1172
|
+
...fields,
|
|
1173
|
+
actionData: undefined,
|
|
1174
|
+
pending: false,
|
|
1175
|
+
}
|
|
843
1176
|
emit()
|
|
844
1177
|
} catch (err) {
|
|
845
1178
|
if (mine === generation) {
|
|
@@ -874,7 +1207,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
874
1207
|
// swallowed - a failed targeted refresh must not fail the mutation that triggered it.
|
|
875
1208
|
const refreshMounted = async (paths: readonly string[], skipActive: boolean): Promise<void> => {
|
|
876
1209
|
const jobs: Promise<void>[] = []
|
|
877
|
-
if (!skipActive
|
|
1210
|
+
if (!skipActive) jobs.push(refetchActive((path) => paths.includes(path)).catch(() => {}))
|
|
878
1211
|
for (const entry of fetchers.values()) jobs.push(entry.refreshIfShowing(paths))
|
|
879
1212
|
await Promise.all(jobs)
|
|
880
1213
|
}
|
|
@@ -884,6 +1217,8 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
884
1217
|
let fGeneration = 0
|
|
885
1218
|
let fAbort: AbortController | undefined
|
|
886
1219
|
let loadedPath: string | undefined
|
|
1220
|
+
// `data` as loaded, with the response text it was read from.
|
|
1221
|
+
let shown: LoadedRouteData | undefined
|
|
887
1222
|
const fListeners = new Set<() => void>()
|
|
888
1223
|
const fEmit = (): void => {
|
|
889
1224
|
if (fListeners.size > 0) for (const l of [...fListeners]) l() // snapshot for defensiveness
|
|
@@ -899,11 +1234,15 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
899
1234
|
fState = { ...fState, pending: true }
|
|
900
1235
|
fEmit()
|
|
901
1236
|
try {
|
|
902
|
-
|
|
1237
|
+
// Loading the path again is a reload of what this fetcher shows.
|
|
1238
|
+
const before = path === loadedPath ? shown : undefined
|
|
1239
|
+
const [, fresh] = await Promise.all([
|
|
903
1240
|
loadModule?.(matched.routeId),
|
|
904
|
-
loadRouteData(path, matched, ac.signal),
|
|
1241
|
+
loadRouteData(path, matched, ac.signal, false, undefined, undefined, before),
|
|
905
1242
|
])
|
|
906
1243
|
if (mine !== fGeneration) return // a newer load/submit on THIS fetcher superseded us
|
|
1244
|
+
const loaded = before === undefined ? fresh : shareShown(matched.routeId, before, fresh)
|
|
1245
|
+
shown = loaded
|
|
907
1246
|
// Record the loaded path only on SUCCESS - a thrown or superseded load must not
|
|
908
1247
|
// leave `loadedPath` pointing at a path this fetcher never actually showed, or a later
|
|
909
1248
|
// `X-Nifra-Revalidate` for it would spuriously refetch onto unexpected data.
|
|
@@ -950,6 +1289,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
950
1289
|
body,
|
|
951
1290
|
headers: { [DATA_HEADER]: "1" },
|
|
952
1291
|
})
|
|
1292
|
+
dropPrefetched()
|
|
953
1293
|
if (!res.ok)
|
|
954
1294
|
throw new Error(`[nifra/web] fetcher action failed (${res.status}): ${action}`)
|
|
955
1295
|
const actionData = res.status === 204 ? undefined : await readResponseData(res, ac.signal)
|
|
@@ -995,12 +1335,25 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
995
1335
|
navigate: async (path) => {
|
|
996
1336
|
const matched = match(path)
|
|
997
1337
|
if (matched === null) return
|
|
1338
|
+
// The target an open boundary already shows (a `#fragment` entry inside it, a link to itself):
|
|
1339
|
+
// reloading it would rebuild the boundary and drop its action result. One in its error state
|
|
1340
|
+
// loads again, so a link can retry it.
|
|
1341
|
+
if (
|
|
1342
|
+
state.intercepted !== undefined &&
|
|
1343
|
+
state.boundaries?.[state.intercepted.boundary]?.status === "ready" &&
|
|
1344
|
+
!state.pending &&
|
|
1345
|
+
path === state.path
|
|
1346
|
+
) {
|
|
1347
|
+
emit()
|
|
1348
|
+
return
|
|
1349
|
+
}
|
|
998
1350
|
// Client-only search fast path: same route + same pathname, and every changed search key is
|
|
999
1351
|
// declared client-only. Publish the new URL (so `useSearch`/`useLocation` update) WITHOUT a data
|
|
1000
1352
|
// fetch - the loader's inputs did not change. Any loader-affecting key change falls through to the
|
|
1001
1353
|
// normal match → fetch → publish below, so data is never stale. Abort an in-flight fetch first: a
|
|
1002
1354
|
// rapid `?tab` after a real nav must not let the older fetch overwrite this synchronous update.
|
|
1003
1355
|
if (
|
|
1356
|
+
state.intercepted === undefined &&
|
|
1004
1357
|
matched.routeId === state.routeId &&
|
|
1005
1358
|
pathnameOf(path) === pathnameOf(state.path) &&
|
|
1006
1359
|
isClientOnlySearchChange(
|
|
@@ -1009,11 +1362,14 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1009
1362
|
searchClientKeys[state.routeId] ?? [],
|
|
1010
1363
|
)
|
|
1011
1364
|
) {
|
|
1012
|
-
++generation // supersede any in-flight navigation so its late result is dropped
|
|
1365
|
+
const mine = ++generation // supersede any in-flight navigation so its late result is dropped
|
|
1013
1366
|
navAbort?.abort()
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1367
|
+
await present(() => {
|
|
1368
|
+
if (mine !== generation) return
|
|
1369
|
+
cachePut(path, dataFields(state))
|
|
1370
|
+
state = { ...state, path, pending: false, pendingPath: undefined }
|
|
1371
|
+
emit()
|
|
1372
|
+
})
|
|
1017
1373
|
return
|
|
1018
1374
|
}
|
|
1019
1375
|
const mine = ++generation
|
|
@@ -1026,57 +1382,73 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1026
1382
|
const [to, target, loaded] = await loadFollowing(mine, path, matched, ac.signal, true, true)
|
|
1027
1383
|
if (mine !== generation) return // a newer navigation superseded this one - drop the stale result
|
|
1028
1384
|
cachePut(to, loaded) // keep the keyed cache coherent with what we publish
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1385
|
+
// The swap can wait a frame for the presenter, and a newer navigation started meanwhile owns it.
|
|
1386
|
+
await present(() => {
|
|
1387
|
+
if (mine !== generation) return
|
|
1388
|
+
// The page's own URL is never intercepted: navigating there (Back out of a boundary, a link
|
|
1389
|
+
// to itself) shows the page, even when a boundary's pattern matches it.
|
|
1390
|
+
const pagePath = pagePathOf(state)
|
|
1391
|
+
const interception =
|
|
1392
|
+
loaded.terminalRouteId === undefined && pathnameOf(to) !== pathnameOf(pagePath)
|
|
1393
|
+
? interceptionFor(state.routeId, to, target)
|
|
1394
|
+
: undefined
|
|
1395
|
+
if (interception !== undefined) {
|
|
1396
|
+
// The target's ordinary data request has already completed (including its server authz and
|
|
1397
|
+
// optional client wrapper). Only the presentation route is retained; the current page stays
|
|
1398
|
+
// mounted underneath the named slot. A target that returned a terminal status never reaches
|
|
1399
|
+
// this branch, so an unauthorized/missing target cannot become a modal.
|
|
1400
|
+
state = {
|
|
1401
|
+
...state,
|
|
1402
|
+
path: to,
|
|
1403
|
+
boundaries: holdTarget(state.boundaries, interception, loaded),
|
|
1404
|
+
intercepted: { boundary: interception.name, pagePath },
|
|
1405
|
+
pending: false,
|
|
1406
|
+
pendingPath: undefined,
|
|
1407
|
+
}
|
|
1408
|
+
emit()
|
|
1409
|
+
return
|
|
1410
|
+
}
|
|
1038
1411
|
state = {
|
|
1039
|
-
|
|
1412
|
+
routeId: loaded.terminalRouteId ?? target.routeId,
|
|
1413
|
+
params: loaded.terminalRouteId === undefined ? target.params : {},
|
|
1040
1414
|
path: to,
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
[interception.name]: {
|
|
1044
|
-
name: interception.name,
|
|
1045
|
-
mode: interception.mode,
|
|
1046
|
-
status: "ready",
|
|
1047
|
-
data: loaded.data,
|
|
1048
|
-
...(interception.errorId === undefined ? {} : { errorId: interception.errorId }),
|
|
1049
|
-
},
|
|
1050
|
-
},
|
|
1051
|
-
actionData: undefined,
|
|
1415
|
+
...dataFields(loaded),
|
|
1416
|
+
actionData: undefined, // a fresh navigation has no action result
|
|
1052
1417
|
pending: false,
|
|
1053
|
-
pendingPath: undefined,
|
|
1054
1418
|
}
|
|
1419
|
+
shownPage = loaded
|
|
1055
1420
|
emit()
|
|
1056
|
-
|
|
1057
|
-
}
|
|
1058
|
-
state = {
|
|
1059
|
-
routeId: loaded.terminalRouteId ?? target.routeId,
|
|
1060
|
-
params: loaded.terminalRouteId === undefined ? target.params : {},
|
|
1061
|
-
path: to,
|
|
1062
|
-
...dataFields(loaded),
|
|
1063
|
-
actionData: undefined, // a fresh navigation has no action result
|
|
1064
|
-
pending: false,
|
|
1065
|
-
}
|
|
1066
|
-
emit()
|
|
1421
|
+
})
|
|
1067
1422
|
} catch (err) {
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
if (mine
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
}
|
|
1423
|
+
// A newer navigation or submit owns the page, and aborting this one's request is how it took
|
|
1424
|
+
// over: as with a submit, the superseded navigation settles quietly.
|
|
1425
|
+
if (mine !== generation) return
|
|
1426
|
+
// Clear our pending flag + target and rethrow - the caller decides how to recover (the history
|
|
1427
|
+
// layer falls back to a full-page navigation).
|
|
1428
|
+
state = { ...state, pending: false, pendingPath: undefined }
|
|
1429
|
+
emit()
|
|
1074
1430
|
throw err
|
|
1075
1431
|
}
|
|
1076
1432
|
},
|
|
1077
1433
|
submit: async (action, body, opts) => {
|
|
1078
1434
|
const mine = ++generation
|
|
1079
1435
|
const previousActionData = state.actionData
|
|
1436
|
+
// An action posted to the target an intercept boundary holds answers that boundary, not the page.
|
|
1437
|
+
const actionBoundary =
|
|
1438
|
+
state.intercepted !== undefined && pathnameOf(action) === pathnameOf(state.path)
|
|
1439
|
+
? state.intercepted.boundary
|
|
1440
|
+
: undefined
|
|
1441
|
+
const previousBoundaryActionData =
|
|
1442
|
+
actionBoundary === undefined ? undefined : state.boundaries?.[actionBoundary]?.actionData
|
|
1443
|
+
// The state with `value` as the action's result, on whichever owns the action.
|
|
1444
|
+
const answered = (
|
|
1445
|
+
current: { readonly boundaries?: BoundaryStates | undefined },
|
|
1446
|
+
value: unknown,
|
|
1447
|
+
): Partial<RouterState> => {
|
|
1448
|
+
if (actionBoundary === undefined) return { actionData: value }
|
|
1449
|
+
const boundaries = withActionData(current.boundaries, actionBoundary, value)
|
|
1450
|
+
return boundaries === undefined ? {} : { boundaries }
|
|
1451
|
+
}
|
|
1080
1452
|
// A superseding navigation/submit aborts this submit's FOLLOW-UP reads (revalidation / redirect
|
|
1081
1453
|
// fetch + their NDJSON drains) - not the mutation POST itself, which should complete.
|
|
1082
1454
|
// Wire into `navAbort` like `navigate`/`refetchActive`, so a later nav cancels the in-flight read.
|
|
@@ -1097,7 +1469,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1097
1469
|
try {
|
|
1098
1470
|
const prepared = await prepareClientAction(action, body, ac.signal)
|
|
1099
1471
|
if (Object.hasOwn(prepared, "optimisticData")) {
|
|
1100
|
-
state = { ...state,
|
|
1472
|
+
state = { ...state, ...answered(state, prepared.optimisticData) }
|
|
1101
1473
|
emit()
|
|
1102
1474
|
}
|
|
1103
1475
|
const res = await fetch(appUrl(action), {
|
|
@@ -1107,6 +1479,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1107
1479
|
})
|
|
1108
1480
|
// Whatever the status, the server took the POST: the action may have run before it failed.
|
|
1109
1481
|
posted = true
|
|
1482
|
+
dropPrefetched()
|
|
1110
1483
|
if (!res.ok) throw new Error(`[nifra/web] action failed (${res.status}): ${action}`)
|
|
1111
1484
|
const redirectTo = res.headers.get(REDIRECT_HEADER)
|
|
1112
1485
|
if (redirectTo !== null) {
|
|
@@ -1135,6 +1508,7 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1135
1508
|
actionData: undefined,
|
|
1136
1509
|
pending: false,
|
|
1137
1510
|
}
|
|
1511
|
+
shownPage = loaded
|
|
1138
1512
|
emit()
|
|
1139
1513
|
return
|
|
1140
1514
|
}
|
|
@@ -1145,32 +1519,43 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1145
1519
|
// Routes the action declared changed (via the `revalidate()` helper → `X-Nifra-Revalidate`),
|
|
1146
1520
|
// validated against the manifest.
|
|
1147
1521
|
const changed = parseRevalidate(res.headers.get(REVALIDATE_HEADER))
|
|
1148
|
-
// Revalidate
|
|
1149
|
-
// out (`revalidate: false`). A
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
const
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
loadRouteData(state.path, active, ac.signal),
|
|
1159
|
-
)
|
|
1522
|
+
// Revalidate what is on screen so the mutation is reflected - the page, and a target an
|
|
1523
|
+
// intercept boundary holds - unless the caller opted out (`revalidate: false`). A
|
|
1524
|
+
// server-declared revalidate of a path on screen overrides the opt-out (the server says it
|
|
1525
|
+
// changed, so stale data would be wrong). Default is to revalidate.
|
|
1526
|
+
const pagePath = pagePathOf(state)
|
|
1527
|
+
const reloadPage = opts?.revalidate !== false || changed.includes(pagePath)
|
|
1528
|
+
const reloadTarget =
|
|
1529
|
+
state.intercepted !== undefined &&
|
|
1530
|
+
(opts?.revalidate !== false || changed.includes(state.path))
|
|
1531
|
+
const { fields, route } = await reloadActive(ac.signal, reloadPage, reloadTarget, mine)
|
|
1160
1532
|
if (mine !== generation) return
|
|
1161
|
-
cachePut(state.path, loaded) // the revalidated (or kept) data is now the cache's truth
|
|
1162
1533
|
// Mark the OTHER changed routes stale so the next access refetches.
|
|
1163
|
-
markStale(changed.filter((p) => p !== state.path))
|
|
1534
|
+
markStale(changed.filter((p) => p !== pagePath && (!reloadTarget || p !== state.path)))
|
|
1164
1535
|
// Reconcile: publish the revalidated data + actionData; omit `submission` (the optimistic
|
|
1165
|
-
// window is over - the real data now drives the view).
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1536
|
+
// window is over - the real data now drives the view). A reload that names another route (a
|
|
1537
|
+
// terminal answer for the page) renders it in the page's place, closing any boundary, and the
|
|
1538
|
+
// action's result goes with the page.
|
|
1539
|
+
state =
|
|
1540
|
+
route === undefined
|
|
1541
|
+
? {
|
|
1542
|
+
routeId: state.routeId,
|
|
1543
|
+
params: state.params,
|
|
1544
|
+
path: state.path,
|
|
1545
|
+
...fields,
|
|
1546
|
+
actionData: previousActionData,
|
|
1547
|
+
...answered(fields, actionData),
|
|
1548
|
+
pending: false,
|
|
1549
|
+
...(state.intercepted === undefined ? {} : { intercepted: state.intercepted }),
|
|
1550
|
+
}
|
|
1551
|
+
: {
|
|
1552
|
+
routeId: route.routeId,
|
|
1553
|
+
params: route.params,
|
|
1554
|
+
path: state.path,
|
|
1555
|
+
...fields,
|
|
1556
|
+
actionData: undefined,
|
|
1557
|
+
pending: false,
|
|
1558
|
+
}
|
|
1174
1559
|
emit()
|
|
1175
1560
|
// Refresh any mounted fetcher showing one of the changed routes (the active route was just
|
|
1176
1561
|
// revalidated inline above, so skip it here). Best-effort - never rejects the submit.
|
|
@@ -1185,26 +1570,33 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1185
1570
|
path: state.path,
|
|
1186
1571
|
...dataFields(state),
|
|
1187
1572
|
actionData: previousActionData,
|
|
1573
|
+
...(actionBoundary === undefined
|
|
1574
|
+
? {}
|
|
1575
|
+
: answered(dataFields(state), previousBoundaryActionData)),
|
|
1188
1576
|
pending: false,
|
|
1577
|
+
...(state.intercepted === undefined ? {} : { intercepted: state.intercepted }),
|
|
1189
1578
|
}
|
|
1190
1579
|
emit()
|
|
1191
1580
|
}
|
|
1192
1581
|
if (!posted) throw err
|
|
1193
1582
|
if (mine !== generation) return // a newer navigation or submit owns the page
|
|
1194
|
-
|
|
1583
|
+
// Otherwise show the route that owns the action, as a native post to it would.
|
|
1584
|
+
const owner = actionBoundary === undefined ? pagePathOf(state) : state.path
|
|
1585
|
+
throw redirectOf(err) === undefined ? redirected(landing ?? owner, err) : err
|
|
1195
1586
|
}
|
|
1196
1587
|
},
|
|
1197
1588
|
hydrate,
|
|
1198
1589
|
invalidate: async (paths) => {
|
|
1199
1590
|
// Mark targeted cache entries stale (all entries when no `paths`) - unmounted ones refetch
|
|
1200
|
-
// lazily on next access.
|
|
1591
|
+
// lazily on next access. A warmed prefetch of a target is dropped: it would show the old data.
|
|
1201
1592
|
const targets = paths ?? [...cache.keys()]
|
|
1202
1593
|
markStale(targets)
|
|
1594
|
+
if (paths === undefined) dropPrefetched()
|
|
1203
1595
|
// Refresh mounted readers now: the active route (when in scope - an explicit list including it,
|
|
1204
1596
|
// or an invalidate-all) + any fetcher showing a targeted path. The active refetch rejects on
|
|
1205
1597
|
// failure (the caller asked to refresh); fetcher refreshes are best-effort (swallowed).
|
|
1206
1598
|
const jobs: Promise<void>[] = []
|
|
1207
|
-
|
|
1599
|
+
jobs.push(refetchActive(paths === undefined ? undefined : (path) => paths.includes(path)))
|
|
1208
1600
|
for (const entry of fetchers.values()) jobs.push(entry.refreshIfShowing(targets))
|
|
1209
1601
|
await Promise.all(jobs)
|
|
1210
1602
|
},
|
|
@@ -1212,24 +1604,34 @@ export function createClientRouter(options: ClientRouterOptions): ClientRouter {
|
|
|
1212
1604
|
if (inflight.has(path) || freshPrefetch(path) !== undefined) return
|
|
1213
1605
|
const matched = match(path)
|
|
1214
1606
|
if (matched === null) return
|
|
1215
|
-
|
|
1607
|
+
const token = {}
|
|
1608
|
+
inflight.set(path, token)
|
|
1609
|
+
const epoch = readEpoch()
|
|
1216
1610
|
try {
|
|
1217
1611
|
// Unwrapped here, so a prefetched entry is the same shape `navigate` builds state from -
|
|
1218
1612
|
// otherwise a prefetch hit would publish an envelope where a miss publishes loader data.
|
|
1219
1613
|
const [, loaded] = await Promise.all([
|
|
1220
1614
|
loadModule?.(matched.routeId, path),
|
|
1221
|
-
loadRouteData(path, matched),
|
|
1615
|
+
loadRouteData(path, matched, undefined, false, "prefetch"),
|
|
1222
1616
|
])
|
|
1223
1617
|
prefetched.delete(path) // a stale entry re-enters as the newest
|
|
1224
1618
|
if (prefetched.size >= MAX_PREFETCH) {
|
|
1225
1619
|
const oldest = prefetched.keys().next().value
|
|
1226
1620
|
if (oldest !== undefined) prefetched.delete(oldest)
|
|
1227
1621
|
}
|
|
1228
|
-
|
|
1622
|
+
// A prefetched hole is an empty host nothing fills; the navigation fetches it, cancellably.
|
|
1623
|
+
const holes = Object.values(loaded.boundaries ?? {}).some((state) => state.ppr === true)
|
|
1624
|
+
if (
|
|
1625
|
+
loaded.serverComponents === undefined &&
|
|
1626
|
+
!holes &&
|
|
1627
|
+
inflight.get(path) === token &&
|
|
1628
|
+
epoch === readEpoch()
|
|
1629
|
+
)
|
|
1630
|
+
prefetched.set(path, [loaded, Date.now(), epoch])
|
|
1229
1631
|
} catch {
|
|
1230
1632
|
// Best-effort: a failed prefetch just means the eventual navigate fetches normally.
|
|
1231
1633
|
} finally {
|
|
1232
|
-
inflight.delete(path)
|
|
1634
|
+
if (inflight.get(path) === token) inflight.delete(path)
|
|
1233
1635
|
}
|
|
1234
1636
|
},
|
|
1235
1637
|
fetcher: (key) => {
|