@mandujs/core 0.53.2 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -380
- package/src/diagnose/checks.ts +832 -720
- package/src/diagnose/index.ts +17 -16
- package/src/diagnose/run.ts +93 -91
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
package/src/client/hydrate.ts
CHANGED
|
@@ -1,340 +1,340 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu Per-Island Hydration Scheduler
|
|
3
|
-
*
|
|
4
|
-
* Phase 18.δ — Formal hydration strategy spec for Islands.
|
|
5
|
-
*
|
|
6
|
-
* Strategies (Astro-compatible naming):
|
|
7
|
-
* - `load` — hydrate immediately (current default; equiv. to Astro `client:load`)
|
|
8
|
-
* - `idle` — `requestIdleCallback` (Astro `client:idle`)
|
|
9
|
-
* - `visible` — `IntersectionObserver` rootMargin 200px (Astro `client:visible`)
|
|
10
|
-
* - `interaction` — hydrate on first click/touchstart/keydown (Astro `client:only` equivalent for events)
|
|
11
|
-
* - `media(<query>)` — hydrate only when `matchMedia(query).matches` (Astro `client:media`)
|
|
12
|
-
*
|
|
13
|
-
* This module is the **single source of truth** for hydration scheduling on
|
|
14
|
-
* the client. It is intentionally side-effect-free on import so that:
|
|
15
|
-
* 1. Bundlers can tree-shake unused strategies.
|
|
16
|
-
* 2. Unit tests can import it under happy-dom/JSDOM without triggering
|
|
17
|
-
* global mutation (no `document.querySelectorAll` on module eval).
|
|
18
|
-
*
|
|
19
|
-
* The bundler-generated runtime (`bundler/build.ts::generateRuntimeSource`)
|
|
20
|
-
* delegates strategy selection to `scheduleHydration()` here — SSR emits the
|
|
21
|
-
* `data-hydrate` attribute, the runtime reads it and dispatches.
|
|
22
|
-
*
|
|
23
|
-
* Design contract:
|
|
24
|
-
* - Every strategy invokes its `hydrate` callback at most ONCE.
|
|
25
|
-
* - Every strategy returns a `dispose()` cleanup that detaches observers
|
|
26
|
-
* and event listeners (no memory leaks on route change / unmount).
|
|
27
|
-
* - Unknown / malformed strategies fall back to `load` with a console
|
|
28
|
-
* warning so broken islands never silently stay un-interactive.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
export type HydrationStrategyName =
|
|
32
|
-
| "load"
|
|
33
|
-
| "idle"
|
|
34
|
-
| "visible"
|
|
35
|
-
| "interaction"
|
|
36
|
-
| "media";
|
|
37
|
-
|
|
38
|
-
export interface ParsedStrategy {
|
|
39
|
-
name: HydrationStrategyName;
|
|
40
|
-
/** media query string when `name === "media"`, else `undefined`. */
|
|
41
|
-
media?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** `rootMargin` used by the `visible` IntersectionObserver. */
|
|
45
|
-
export const VISIBLE_ROOT_MARGIN = "200px";
|
|
46
|
-
|
|
47
|
-
/** Events that trigger `interaction` hydration. */
|
|
48
|
-
export const INTERACTION_EVENTS = ["click", "touchstart", "keydown"] as const;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Parse a `data-hydrate` attribute value into a normalized strategy.
|
|
52
|
-
*
|
|
53
|
-
* Accepts:
|
|
54
|
-
* - `"load"` | `"idle"` | `"visible"` | `"interaction"`
|
|
55
|
-
* - `"media(min-width: 768px)"` — media query in parentheses
|
|
56
|
-
*
|
|
57
|
-
* Returns `{ name: "load" }` with a `console.warn` for unknown/malformed
|
|
58
|
-
* inputs (fail-open default — never leave an island dead).
|
|
59
|
-
*/
|
|
60
|
-
export function parseHydrateStrategy(
|
|
61
|
-
attr: string | null | undefined,
|
|
62
|
-
): ParsedStrategy {
|
|
63
|
-
if (!attr) return { name: "load" };
|
|
64
|
-
|
|
65
|
-
const trimmed = attr.trim();
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
trimmed === "load" ||
|
|
69
|
-
trimmed === "idle" ||
|
|
70
|
-
trimmed === "visible" ||
|
|
71
|
-
trimmed === "interaction"
|
|
72
|
-
) {
|
|
73
|
-
return { name: trimmed };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// media(<query>) — extract inner query
|
|
77
|
-
const mediaMatch = /^media\(\s*(.+?)\s*\)$/.exec(trimmed);
|
|
78
|
-
if (mediaMatch && mediaMatch[1]) {
|
|
79
|
-
return { name: "media", media: mediaMatch[1] };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Fallback: legacy `immediate` alias → `load`
|
|
83
|
-
if (trimmed === "immediate") return { name: "load" };
|
|
84
|
-
|
|
85
|
-
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
86
|
-
console.warn(
|
|
87
|
-
`[Mandu] Unknown hydrate strategy "${attr}", falling back to "load".`,
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
return { name: "load" };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* A function returned by each strategy scheduler. Invoke to detach any
|
|
95
|
-
* observers / listeners BEFORE the actual hydration kicks in. Safe to call
|
|
96
|
-
* multiple times (idempotent).
|
|
97
|
-
*/
|
|
98
|
-
export type Disposer = () => void;
|
|
99
|
-
|
|
100
|
-
function noopDispose(): Disposer {
|
|
101
|
-
return () => {};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* `load` — hydrate synchronously on next microtask.
|
|
106
|
-
*
|
|
107
|
-
* We use a microtask (Promise.resolve().then) rather than calling `hydrate`
|
|
108
|
-
* directly so that callers can install listeners on `mandu:hydrated` events
|
|
109
|
-
* after `scheduleHydration` returns. Matches Astro `client:load` semantics.
|
|
110
|
-
*/
|
|
111
|
-
function scheduleLoad(hydrate: () => void): Disposer {
|
|
112
|
-
let cancelled = false;
|
|
113
|
-
void Promise.resolve().then(() => {
|
|
114
|
-
if (!cancelled) hydrate();
|
|
115
|
-
});
|
|
116
|
-
return () => {
|
|
117
|
-
cancelled = true;
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* `idle` — hydrate when the browser is idle.
|
|
123
|
-
* Falls back to `setTimeout(200)` when `requestIdleCallback` is unavailable
|
|
124
|
-
* (Safari < 16.4, some WebViews).
|
|
125
|
-
*/
|
|
126
|
-
function scheduleIdle(hydrate: () => void): Disposer {
|
|
127
|
-
const w = typeof window !== "undefined" ? window : undefined;
|
|
128
|
-
if (!w) return noopDispose();
|
|
129
|
-
|
|
130
|
-
if (typeof w.requestIdleCallback === "function") {
|
|
131
|
-
const handle = w.requestIdleCallback(() => hydrate());
|
|
132
|
-
return () => {
|
|
133
|
-
if (typeof w.cancelIdleCallback === "function") {
|
|
134
|
-
w.cancelIdleCallback(handle);
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const timer = setTimeout(hydrate, 200);
|
|
140
|
-
return () => clearTimeout(timer);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* `visible` — hydrate when the element enters the viewport (+200px margin).
|
|
145
|
-
* Falls back to immediate hydration when `IntersectionObserver` is missing.
|
|
146
|
-
*/
|
|
147
|
-
function scheduleVisible(
|
|
148
|
-
element: Element,
|
|
149
|
-
hydrate: () => void,
|
|
150
|
-
): Disposer {
|
|
151
|
-
if (typeof IntersectionObserver === "undefined") {
|
|
152
|
-
hydrate();
|
|
153
|
-
return noopDispose();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
let disposed = false;
|
|
157
|
-
const observer = new IntersectionObserver(
|
|
158
|
-
(entries) => {
|
|
159
|
-
if (disposed) return;
|
|
160
|
-
for (const entry of entries) {
|
|
161
|
-
if (entry.isIntersecting) {
|
|
162
|
-
disposed = true;
|
|
163
|
-
observer.disconnect();
|
|
164
|
-
hydrate();
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
{ rootMargin: VISIBLE_ROOT_MARGIN },
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
// `display:contents` wrappers have zero layout size → observe the first
|
|
173
|
-
// element child when available so IntersectionObserver sees real geometry.
|
|
174
|
-
const target = resolveObservationTarget(element);
|
|
175
|
-
observer.observe(target);
|
|
176
|
-
|
|
177
|
-
return () => {
|
|
178
|
-
disposed = true;
|
|
179
|
-
observer.disconnect();
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* `interaction` — hydrate on the first click / touchstart / keydown within
|
|
185
|
-
* the island. Matches the task spec (click/touch/keydown only; no
|
|
186
|
-
* mouseenter or pointerdown to keep scroll & hover passive).
|
|
187
|
-
*/
|
|
188
|
-
function scheduleInteraction(
|
|
189
|
-
element: Element,
|
|
190
|
-
hydrate: () => void,
|
|
191
|
-
): Disposer {
|
|
192
|
-
let fired = false;
|
|
193
|
-
const target = resolveObservationTarget(element);
|
|
194
|
-
|
|
195
|
-
const onEvent = () => {
|
|
196
|
-
if (fired) return;
|
|
197
|
-
fired = true;
|
|
198
|
-
dispose();
|
|
199
|
-
hydrate();
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
const dispose: Disposer = () => {
|
|
203
|
-
for (const evt of INTERACTION_EVENTS) {
|
|
204
|
-
target.removeEventListener(evt, onEvent, true);
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
for (const evt of INTERACTION_EVENTS) {
|
|
209
|
-
// Capture phase so the island hydrates BEFORE the user's click bubbles
|
|
210
|
-
// to their still-dehydrated event handler.
|
|
211
|
-
target.addEventListener(evt, onEvent, { capture: true, once: false });
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return dispose;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* `media(<query>)` — hydrate only when `matchMedia(query).matches` becomes
|
|
219
|
-
* true. If already matching on mount, hydrate immediately; otherwise wait
|
|
220
|
-
* for the first `change` event.
|
|
221
|
-
*/
|
|
222
|
-
function scheduleMedia(query: string, hydrate: () => void): Disposer {
|
|
223
|
-
if (
|
|
224
|
-
typeof window === "undefined" ||
|
|
225
|
-
typeof window.matchMedia !== "function"
|
|
226
|
-
) {
|
|
227
|
-
// No matchMedia support → fall back to `load` so the island is at least
|
|
228
|
-
// interactive. Conservative: users can't debug silent no-ops.
|
|
229
|
-
hydrate();
|
|
230
|
-
return noopDispose();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const mql = window.matchMedia(query);
|
|
234
|
-
if (mql.matches) {
|
|
235
|
-
hydrate();
|
|
236
|
-
return noopDispose();
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
let disposed = false;
|
|
240
|
-
const onChange = (e: MediaQueryListEvent) => {
|
|
241
|
-
if (disposed) return;
|
|
242
|
-
if (e.matches) {
|
|
243
|
-
disposed = true;
|
|
244
|
-
detach();
|
|
245
|
-
hydrate();
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const detach: Disposer = () => {
|
|
250
|
-
if (typeof mql.removeEventListener === "function") {
|
|
251
|
-
mql.removeEventListener("change", onChange);
|
|
252
|
-
} else if (typeof (mql as unknown as { removeListener?: (l: unknown) => void }).removeListener === "function") {
|
|
253
|
-
// Safari < 14 legacy API
|
|
254
|
-
(mql as unknown as { removeListener: (l: (e: MediaQueryListEvent) => void) => void }).removeListener(onChange);
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
if (typeof mql.addEventListener === "function") {
|
|
259
|
-
mql.addEventListener("change", onChange);
|
|
260
|
-
} else if (typeof (mql as unknown as { addListener?: (l: unknown) => void }).addListener === "function") {
|
|
261
|
-
(mql as unknown as { addListener: (l: (e: MediaQueryListEvent) => void) => void }).addListener(onChange);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
return () => {
|
|
265
|
-
disposed = true;
|
|
266
|
-
detach();
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Resolve the DOM node to actually observe / listen on. Island wrappers use
|
|
272
|
-
* `style="display:contents"` which has zero layout box — IntersectionObserver
|
|
273
|
-
* refuses to fire for such elements. Promote to the first element child when
|
|
274
|
-
* available, else fall back to the wrapper itself.
|
|
275
|
-
*/
|
|
276
|
-
function resolveObservationTarget(element: Element): Element {
|
|
277
|
-
if (typeof window === "undefined" || typeof getComputedStyle !== "function") {
|
|
278
|
-
return element;
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
const cs = getComputedStyle(element);
|
|
282
|
-
if (cs.display === "contents" && element.firstElementChild) {
|
|
283
|
-
return element.firstElementChild;
|
|
284
|
-
}
|
|
285
|
-
} catch {
|
|
286
|
-
/* happy-dom may reject partial stylesheet queries — treat as layout element */
|
|
287
|
-
}
|
|
288
|
-
return element;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Public entry — schedule hydration of `element` according to `strategy`,
|
|
293
|
-
* invoking `hydrate` at most once. Returns a disposer for cleanup.
|
|
294
|
-
*
|
|
295
|
-
* Unknown strategies degrade to `load` with a console warning.
|
|
296
|
-
*
|
|
297
|
-
* @param element — the island root (typically `[data-mandu-island]`)
|
|
298
|
-
* @param strategy — either a `ParsedStrategy` from `parseHydrateStrategy`
|
|
299
|
-
* OR a raw attribute string (e.g. `"media(min-width: 768px)"`)
|
|
300
|
-
* @param hydrate — callback invoked once when the strategy trigger fires
|
|
301
|
-
*/
|
|
302
|
-
export function scheduleHydration(
|
|
303
|
-
element: Element,
|
|
304
|
-
strategy: ParsedStrategy | string | null | undefined,
|
|
305
|
-
hydrate: () => void,
|
|
306
|
-
): Disposer {
|
|
307
|
-
const parsed =
|
|
308
|
-
typeof strategy === "string" || strategy == null
|
|
309
|
-
? parseHydrateStrategy(strategy as string | null | undefined)
|
|
310
|
-
: strategy;
|
|
311
|
-
|
|
312
|
-
switch (parsed.name) {
|
|
313
|
-
case "load":
|
|
314
|
-
return scheduleLoad(hydrate);
|
|
315
|
-
case "idle":
|
|
316
|
-
return scheduleIdle(hydrate);
|
|
317
|
-
case "visible":
|
|
318
|
-
return scheduleVisible(element, hydrate);
|
|
319
|
-
case "interaction":
|
|
320
|
-
return scheduleInteraction(element, hydrate);
|
|
321
|
-
case "media":
|
|
322
|
-
return parsed.media
|
|
323
|
-
? scheduleMedia(parsed.media, hydrate)
|
|
324
|
-
: scheduleLoad(hydrate);
|
|
325
|
-
default:
|
|
326
|
-
// Exhaustiveness: if a new strategy is added to the union without a
|
|
327
|
-
// handler, TypeScript flags the `never` check below at compile time.
|
|
328
|
-
((_x: never) => _x)(parsed.name);
|
|
329
|
-
return scheduleLoad(hydrate);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Internal test helpers — expose resolveObservationTarget so that unit
|
|
335
|
-
* tests can verify the `display:contents` promotion rule without relying
|
|
336
|
-
* on public API side-effects.
|
|
337
|
-
*
|
|
338
|
-
* @internal
|
|
339
|
-
*/
|
|
340
|
-
export const _testOnly_resolveObservationTarget = resolveObservationTarget;
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Per-Island Hydration Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Phase 18.δ — Formal hydration strategy spec for Islands.
|
|
5
|
+
*
|
|
6
|
+
* Strategies (Astro-compatible naming):
|
|
7
|
+
* - `load` — hydrate immediately (current default; equiv. to Astro `client:load`)
|
|
8
|
+
* - `idle` — `requestIdleCallback` (Astro `client:idle`)
|
|
9
|
+
* - `visible` — `IntersectionObserver` rootMargin 200px (Astro `client:visible`)
|
|
10
|
+
* - `interaction` — hydrate on first click/touchstart/keydown (Astro `client:only` equivalent for events)
|
|
11
|
+
* - `media(<query>)` — hydrate only when `matchMedia(query).matches` (Astro `client:media`)
|
|
12
|
+
*
|
|
13
|
+
* This module is the **single source of truth** for hydration scheduling on
|
|
14
|
+
* the client. It is intentionally side-effect-free on import so that:
|
|
15
|
+
* 1. Bundlers can tree-shake unused strategies.
|
|
16
|
+
* 2. Unit tests can import it under happy-dom/JSDOM without triggering
|
|
17
|
+
* global mutation (no `document.querySelectorAll` on module eval).
|
|
18
|
+
*
|
|
19
|
+
* The bundler-generated runtime (`bundler/build.ts::generateRuntimeSource`)
|
|
20
|
+
* delegates strategy selection to `scheduleHydration()` here — SSR emits the
|
|
21
|
+
* `data-hydrate` attribute, the runtime reads it and dispatches.
|
|
22
|
+
*
|
|
23
|
+
* Design contract:
|
|
24
|
+
* - Every strategy invokes its `hydrate` callback at most ONCE.
|
|
25
|
+
* - Every strategy returns a `dispose()` cleanup that detaches observers
|
|
26
|
+
* and event listeners (no memory leaks on route change / unmount).
|
|
27
|
+
* - Unknown / malformed strategies fall back to `load` with a console
|
|
28
|
+
* warning so broken islands never silently stay un-interactive.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type HydrationStrategyName =
|
|
32
|
+
| "load"
|
|
33
|
+
| "idle"
|
|
34
|
+
| "visible"
|
|
35
|
+
| "interaction"
|
|
36
|
+
| "media";
|
|
37
|
+
|
|
38
|
+
export interface ParsedStrategy {
|
|
39
|
+
name: HydrationStrategyName;
|
|
40
|
+
/** media query string when `name === "media"`, else `undefined`. */
|
|
41
|
+
media?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `rootMargin` used by the `visible` IntersectionObserver. */
|
|
45
|
+
export const VISIBLE_ROOT_MARGIN = "200px";
|
|
46
|
+
|
|
47
|
+
/** Events that trigger `interaction` hydration. */
|
|
48
|
+
export const INTERACTION_EVENTS = ["click", "touchstart", "keydown"] as const;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a `data-hydrate` attribute value into a normalized strategy.
|
|
52
|
+
*
|
|
53
|
+
* Accepts:
|
|
54
|
+
* - `"load"` | `"idle"` | `"visible"` | `"interaction"`
|
|
55
|
+
* - `"media(min-width: 768px)"` — media query in parentheses
|
|
56
|
+
*
|
|
57
|
+
* Returns `{ name: "load" }` with a `console.warn` for unknown/malformed
|
|
58
|
+
* inputs (fail-open default — never leave an island dead).
|
|
59
|
+
*/
|
|
60
|
+
export function parseHydrateStrategy(
|
|
61
|
+
attr: string | null | undefined,
|
|
62
|
+
): ParsedStrategy {
|
|
63
|
+
if (!attr) return { name: "load" };
|
|
64
|
+
|
|
65
|
+
const trimmed = attr.trim();
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
trimmed === "load" ||
|
|
69
|
+
trimmed === "idle" ||
|
|
70
|
+
trimmed === "visible" ||
|
|
71
|
+
trimmed === "interaction"
|
|
72
|
+
) {
|
|
73
|
+
return { name: trimmed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// media(<query>) — extract inner query
|
|
77
|
+
const mediaMatch = /^media\(\s*(.+?)\s*\)$/.exec(trimmed);
|
|
78
|
+
if (mediaMatch && mediaMatch[1]) {
|
|
79
|
+
return { name: "media", media: mediaMatch[1] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Fallback: legacy `immediate` alias → `load`
|
|
83
|
+
if (trimmed === "immediate") return { name: "load" };
|
|
84
|
+
|
|
85
|
+
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
86
|
+
console.warn(
|
|
87
|
+
`[Mandu] Unknown hydrate strategy "${attr}", falling back to "load".`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return { name: "load" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A function returned by each strategy scheduler. Invoke to detach any
|
|
95
|
+
* observers / listeners BEFORE the actual hydration kicks in. Safe to call
|
|
96
|
+
* multiple times (idempotent).
|
|
97
|
+
*/
|
|
98
|
+
export type Disposer = () => void;
|
|
99
|
+
|
|
100
|
+
function noopDispose(): Disposer {
|
|
101
|
+
return () => {};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `load` — hydrate synchronously on next microtask.
|
|
106
|
+
*
|
|
107
|
+
* We use a microtask (Promise.resolve().then) rather than calling `hydrate`
|
|
108
|
+
* directly so that callers can install listeners on `mandu:hydrated` events
|
|
109
|
+
* after `scheduleHydration` returns. Matches Astro `client:load` semantics.
|
|
110
|
+
*/
|
|
111
|
+
function scheduleLoad(hydrate: () => void): Disposer {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
void Promise.resolve().then(() => {
|
|
114
|
+
if (!cancelled) hydrate();
|
|
115
|
+
});
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `idle` — hydrate when the browser is idle.
|
|
123
|
+
* Falls back to `setTimeout(200)` when `requestIdleCallback` is unavailable
|
|
124
|
+
* (Safari < 16.4, some WebViews).
|
|
125
|
+
*/
|
|
126
|
+
function scheduleIdle(hydrate: () => void): Disposer {
|
|
127
|
+
const w = typeof window !== "undefined" ? window : undefined;
|
|
128
|
+
if (!w) return noopDispose();
|
|
129
|
+
|
|
130
|
+
if (typeof w.requestIdleCallback === "function") {
|
|
131
|
+
const handle = w.requestIdleCallback(() => hydrate());
|
|
132
|
+
return () => {
|
|
133
|
+
if (typeof w.cancelIdleCallback === "function") {
|
|
134
|
+
w.cancelIdleCallback(handle);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const timer = setTimeout(hydrate, 200);
|
|
140
|
+
return () => clearTimeout(timer);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `visible` — hydrate when the element enters the viewport (+200px margin).
|
|
145
|
+
* Falls back to immediate hydration when `IntersectionObserver` is missing.
|
|
146
|
+
*/
|
|
147
|
+
function scheduleVisible(
|
|
148
|
+
element: Element,
|
|
149
|
+
hydrate: () => void,
|
|
150
|
+
): Disposer {
|
|
151
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
152
|
+
hydrate();
|
|
153
|
+
return noopDispose();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let disposed = false;
|
|
157
|
+
const observer = new IntersectionObserver(
|
|
158
|
+
(entries) => {
|
|
159
|
+
if (disposed) return;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.isIntersecting) {
|
|
162
|
+
disposed = true;
|
|
163
|
+
observer.disconnect();
|
|
164
|
+
hydrate();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{ rootMargin: VISIBLE_ROOT_MARGIN },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// `display:contents` wrappers have zero layout size → observe the first
|
|
173
|
+
// element child when available so IntersectionObserver sees real geometry.
|
|
174
|
+
const target = resolveObservationTarget(element);
|
|
175
|
+
observer.observe(target);
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
disposed = true;
|
|
179
|
+
observer.disconnect();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `interaction` — hydrate on the first click / touchstart / keydown within
|
|
185
|
+
* the island. Matches the task spec (click/touch/keydown only; no
|
|
186
|
+
* mouseenter or pointerdown to keep scroll & hover passive).
|
|
187
|
+
*/
|
|
188
|
+
function scheduleInteraction(
|
|
189
|
+
element: Element,
|
|
190
|
+
hydrate: () => void,
|
|
191
|
+
): Disposer {
|
|
192
|
+
let fired = false;
|
|
193
|
+
const target = resolveObservationTarget(element);
|
|
194
|
+
|
|
195
|
+
const onEvent = () => {
|
|
196
|
+
if (fired) return;
|
|
197
|
+
fired = true;
|
|
198
|
+
dispose();
|
|
199
|
+
hydrate();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const dispose: Disposer = () => {
|
|
203
|
+
for (const evt of INTERACTION_EVENTS) {
|
|
204
|
+
target.removeEventListener(evt, onEvent, true);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
for (const evt of INTERACTION_EVENTS) {
|
|
209
|
+
// Capture phase so the island hydrates BEFORE the user's click bubbles
|
|
210
|
+
// to their still-dehydrated event handler.
|
|
211
|
+
target.addEventListener(evt, onEvent, { capture: true, once: false });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return dispose;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `media(<query>)` — hydrate only when `matchMedia(query).matches` becomes
|
|
219
|
+
* true. If already matching on mount, hydrate immediately; otherwise wait
|
|
220
|
+
* for the first `change` event.
|
|
221
|
+
*/
|
|
222
|
+
function scheduleMedia(query: string, hydrate: () => void): Disposer {
|
|
223
|
+
if (
|
|
224
|
+
typeof window === "undefined" ||
|
|
225
|
+
typeof window.matchMedia !== "function"
|
|
226
|
+
) {
|
|
227
|
+
// No matchMedia support → fall back to `load` so the island is at least
|
|
228
|
+
// interactive. Conservative: users can't debug silent no-ops.
|
|
229
|
+
hydrate();
|
|
230
|
+
return noopDispose();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mql = window.matchMedia(query);
|
|
234
|
+
if (mql.matches) {
|
|
235
|
+
hydrate();
|
|
236
|
+
return noopDispose();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let disposed = false;
|
|
240
|
+
const onChange = (e: MediaQueryListEvent) => {
|
|
241
|
+
if (disposed) return;
|
|
242
|
+
if (e.matches) {
|
|
243
|
+
disposed = true;
|
|
244
|
+
detach();
|
|
245
|
+
hydrate();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const detach: Disposer = () => {
|
|
250
|
+
if (typeof mql.removeEventListener === "function") {
|
|
251
|
+
mql.removeEventListener("change", onChange);
|
|
252
|
+
} else if (typeof (mql as unknown as { removeListener?: (l: unknown) => void }).removeListener === "function") {
|
|
253
|
+
// Safari < 14 legacy API
|
|
254
|
+
(mql as unknown as { removeListener: (l: (e: MediaQueryListEvent) => void) => void }).removeListener(onChange);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (typeof mql.addEventListener === "function") {
|
|
259
|
+
mql.addEventListener("change", onChange);
|
|
260
|
+
} else if (typeof (mql as unknown as { addListener?: (l: unknown) => void }).addListener === "function") {
|
|
261
|
+
(mql as unknown as { addListener: (l: (e: MediaQueryListEvent) => void) => void }).addListener(onChange);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return () => {
|
|
265
|
+
disposed = true;
|
|
266
|
+
detach();
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the DOM node to actually observe / listen on. Island wrappers use
|
|
272
|
+
* `style="display:contents"` which has zero layout box — IntersectionObserver
|
|
273
|
+
* refuses to fire for such elements. Promote to the first element child when
|
|
274
|
+
* available, else fall back to the wrapper itself.
|
|
275
|
+
*/
|
|
276
|
+
function resolveObservationTarget(element: Element): Element {
|
|
277
|
+
if (typeof window === "undefined" || typeof getComputedStyle !== "function") {
|
|
278
|
+
return element;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const cs = getComputedStyle(element);
|
|
282
|
+
if (cs.display === "contents" && element.firstElementChild) {
|
|
283
|
+
return element.firstElementChild;
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
/* happy-dom may reject partial stylesheet queries — treat as layout element */
|
|
287
|
+
}
|
|
288
|
+
return element;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Public entry — schedule hydration of `element` according to `strategy`,
|
|
293
|
+
* invoking `hydrate` at most once. Returns a disposer for cleanup.
|
|
294
|
+
*
|
|
295
|
+
* Unknown strategies degrade to `load` with a console warning.
|
|
296
|
+
*
|
|
297
|
+
* @param element — the island root (typically `[data-mandu-island]`)
|
|
298
|
+
* @param strategy — either a `ParsedStrategy` from `parseHydrateStrategy`
|
|
299
|
+
* OR a raw attribute string (e.g. `"media(min-width: 768px)"`)
|
|
300
|
+
* @param hydrate — callback invoked once when the strategy trigger fires
|
|
301
|
+
*/
|
|
302
|
+
export function scheduleHydration(
|
|
303
|
+
element: Element,
|
|
304
|
+
strategy: ParsedStrategy | string | null | undefined,
|
|
305
|
+
hydrate: () => void,
|
|
306
|
+
): Disposer {
|
|
307
|
+
const parsed =
|
|
308
|
+
typeof strategy === "string" || strategy == null
|
|
309
|
+
? parseHydrateStrategy(strategy as string | null | undefined)
|
|
310
|
+
: strategy;
|
|
311
|
+
|
|
312
|
+
switch (parsed.name) {
|
|
313
|
+
case "load":
|
|
314
|
+
return scheduleLoad(hydrate);
|
|
315
|
+
case "idle":
|
|
316
|
+
return scheduleIdle(hydrate);
|
|
317
|
+
case "visible":
|
|
318
|
+
return scheduleVisible(element, hydrate);
|
|
319
|
+
case "interaction":
|
|
320
|
+
return scheduleInteraction(element, hydrate);
|
|
321
|
+
case "media":
|
|
322
|
+
return parsed.media
|
|
323
|
+
? scheduleMedia(parsed.media, hydrate)
|
|
324
|
+
: scheduleLoad(hydrate);
|
|
325
|
+
default:
|
|
326
|
+
// Exhaustiveness: if a new strategy is added to the union without a
|
|
327
|
+
// handler, TypeScript flags the `never` check below at compile time.
|
|
328
|
+
((_x: never) => _x)(parsed.name);
|
|
329
|
+
return scheduleLoad(hydrate);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Internal test helpers — expose resolveObservationTarget so that unit
|
|
335
|
+
* tests can verify the `display:contents` promotion rule without relying
|
|
336
|
+
* on public API side-effects.
|
|
337
|
+
*
|
|
338
|
+
* @internal
|
|
339
|
+
*/
|
|
340
|
+
export const _testOnly_resolveObservationTarget = resolveObservationTarget;
|