Skip to content

Commit

Permalink
Inline hydration directive scripts (#3605)
Browse files Browse the repository at this point in the history
* Inline hydration scripts

* Adds a changeset

* Update directiveAstroKeys type
  • Loading branch information
matthewp authored Jun 16, 2022
1 parent 816e963 commit 4916b73
Show file tree
Hide file tree
Showing 19 changed files with 203 additions and 174 deletions.
5 changes: 5 additions & 0 deletions .changeset/many-seas-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Inlines hydration scripts
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package-lock.json
# do not commit .env files or any files that end with `.env`
*.env

packages/astro/src/**/*.prebuilt.ts
!packages/astro/vendor/vite/dist
packages/integrations/**/.netlify/

Expand Down
8 changes: 4 additions & 4 deletions packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@
"vendor"
],
"scripts": {
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\"",
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev \"src/**/*.ts\"",
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\" \"src/runtime/client/{idle,load,media,only,visible}.ts\"",
"build": "pnpm run prebuild && astro-scripts build \"src/**/*.ts\" && tsc",
"build:ci": "pnpm run prebuild && astro-scripts build \"src/**/*.ts\"",
"dev": "astro-scripts dev --prebuild \"src/runtime/server/astro-island.ts\" --prebuild \"src/runtime/client/{idle,load,media,only,visible}.ts\" \"src/**/*.ts\"",
"postbuild": "astro-scripts copy \"src/**/*.astro\"",
"benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js",
"test": "mocha --exit --timeout 20000 --ignore **/lit-element.test.js --ignore **/errors.test.js && mocha --timeout 20000 **/lit-element.test.js && mocha --timeout 20000 **/errors.test.js",
Expand Down
2 changes: 0 additions & 2 deletions packages/astro/src/core/build/static-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ export async function staticBuild(opts: StaticBuildOptions) {
...metadata.hydratedComponentPaths(),
// Client-only components
...clientOnlys,
// Any hydration directive like astro/client/idle.js
...metadata.hydrationDirectiveSpecifiers(),
// The client path for each renderer
...renderers
.filter((renderer) => !!renderer.clientEntrypoint)
Expand Down
17 changes: 17 additions & 0 deletions packages/astro/src/runtime/client/hydration-directives.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';

type DirectiveLoader = (get: GetHydrateCallback, opts: HydrateOptions, root: HTMLElement) => void;

declare global {
interface Window {
Astro: {
idle: DirectiveLoader;
load: DirectiveLoader;
media: DirectiveLoader;
only: DirectiveLoader;
visible: DirectiveLoader;
}
}
}

export {};
33 changes: 9 additions & 24 deletions packages/astro/src/runtime/client/idle.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';
(self.Astro = self.Astro || {}).idle = (getHydrateCallback) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
};

/**
* Hydrate this component as soon as the main thread is free
* (or after a short delay, if `requestIdleCallback`) isn't supported
*/
export default async function onIdle(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function idle() {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};

if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb);
} else {
setTimeout(cb, 200);
}
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(cb);
} else {
setTimeout(cb, 200);
}
idle();
}
22 changes: 6 additions & 16 deletions packages/astro/src/runtime/client/load.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';

/**
* Hydrate this component immediately
*/
export default async function onLoad(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function load() {
(self.Astro = self.Astro || {}).load = (getHydrateCallback) => {
(async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
}
load();
}
})();
};


39 changes: 16 additions & 23 deletions packages/astro/src/runtime/client/media.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';

/**
* Hydrate this component when a matching media query is found
*/
export default async function onMedia(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function media() {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};
(self.Astro = self.Astro || {}).media = (getHydrateCallback, options) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
};

if (options.value) {
const mql = matchMedia(options.value);
if (mql.matches) {
cb();
} else {
mql.addEventListener('change', cb, { once: true });
}
if (options.value) {
const mql = matchMedia(options.value);
if (mql.matches) {
cb();
} else {
mql.addEventListener('change', cb, { once: true });
}
}
media();
}
};




20 changes: 7 additions & 13 deletions packages/astro/src/runtime/client/only.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';

/**
* Hydrate this component only on the client
*/
export default async function onOnly(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
async function only() {
(self.Astro = self.Astro || {}).only = (getHydrateCallback) => {
(async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
}
only();
}
})();
};



26 changes: 4 additions & 22 deletions packages/astro/src/runtime/client/visible.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
import type { GetHydrateCallback, HydrateOptions } from '../../@types/astro';
import { notify } from './events';

/**
* Hydrate this component when one of it's children becomes visible
* We target the children because `astro-island` is set to `display: contents`
* which doesn't work with IntersectionObserver
*/
export default async function onVisible(
root: HTMLElement,
options: HydrateOptions,
getHydrateCallback: GetHydrateCallback
) {
let io: IntersectionObserver;

async function visible() {
const cb = async () => {
(self.Astro = self.Astro || {}).visible = (getHydrateCallback, _opts, root) => {
const cb = async () => {
let hydrate = await getHydrateCallback();
await hydrate();
notify();
};

if (io) {
io.disconnect();
}

io = new IntersectionObserver((entries) => {
let io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// As soon as we hydrate, disconnect this IntersectionObserver for every `astro-island`
Expand All @@ -38,7 +23,4 @@ export default async function onVisible(
const child = root.children[i];
io.observe(child);
}
}

visible();
}
};
7 changes: 0 additions & 7 deletions packages/astro/src/runtime/server/astro-island.prebuilt.ts

This file was deleted.

22 changes: 14 additions & 8 deletions packages/astro/src/runtime/server/astro-island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
// Do not import this file directly, instead import the prebuilt one instead.
// pnpm --filter astro run prebuild

type directiveAstroKeys = 'load' | 'idle' | 'visible' | 'media' | 'only';

declare const Astro: {
[k in directiveAstroKeys]: (
fn: () => Promise<() => void>,
opts: Record<string, any>,
root: HTMLElement
) => void;
}

{
interface PropTypeSelector {
[k: string]: (value: any) => any;
Expand Down Expand Up @@ -32,14 +42,10 @@
public hydrator: any;
static observedAttributes = ['props'];
async connectedCallback() {
const [{ default: setup }] = await Promise.all([
import(this.getAttribute('directive-url')!),
import(this.getAttribute('before-hydration-url')!),
]);
window.addEventListener('astro:hydrate', this.hydrate);

const opts = JSON.parse(this.getAttribute('opts')!);
setup(this, opts, async () => {
await import(this.getAttribute('before-hydration-url')!);
const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
Astro[this.getAttribute('client') as directiveAstroKeys](async () => {
const rendererUrl = this.getAttribute('renderer-url');
const [componentModule, { default: hydrator }] = await Promise.all([
import(this.getAttribute('component-url')!),
Expand All @@ -48,7 +54,7 @@
this.Component = componentModule[this.getAttribute('component-export') || 'default'];
this.hydrator = hydrator;
return this.hydrate;
});
}, opts, this);
}
hydrate = () => {
if (!this.hydrator || this.parentElement?.closest('astro-island[ssr]')) {
Expand Down
3 changes: 1 addition & 2 deletions packages/astro/src/runtime/server/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
} from '../../@types/astro';
import { escapeHTML } from './escape.js';
import { serializeProps } from './serialize.js';
import { hydrationSpecifier, serializeListValue } from './util.js';
import { serializeListValue } from './util.js';

const HydrationDirectives = ['load', 'idle', 'media', 'visible', 'only'];

Expand Down Expand Up @@ -129,7 +129,6 @@ export async function generateHydrateScript(

island.props['ssr'] = '';
island.props['client'] = hydrate;
island.props['directive-url'] = await result.resolve(hydrationSpecifier(hydrate));
island.props['before-hydration-url'] = await result.resolve('astro:scripts/before-hydration.js');
island.props['opts'] = escapeHTML(
JSON.stringify({
Expand Down
40 changes: 14 additions & 26 deletions packages/astro/src/runtime/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,17 @@ import type {
SSRLoadedRenderer,
SSRResult,
} from '../../@types/astro';
import islandScript from './astro-island.prebuilt.js';

import { escapeHTML, HTMLString, markHTMLString } from './escape.js';
import { extractDirectives, generateHydrateScript } from './hydration.js';
import { serializeProps } from './serialize.js';
import { shorthash } from './shorthash.js';
import {
determineIfNeedsHydrationScript,
determinesIfNeedsDirectiveScript,
PrescriptType,
getPrescripts
} from './scripts.js';
import { serializeListValue } from './util.js';

export { markHTMLString, markHTMLString as unescapeHTML } from './escape.js';
Expand All @@ -27,18 +33,6 @@ const htmlEnumAttributes = /^(contenteditable|draggable|spellcheck|value)$/i;
// Note: SVG is case-sensitive!
const svgEnumAttributes = /^(autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i;

// This is used to keep track of which requests (pages) have had the hydration script
// appended. We only add the hydration script once per page, and since the SSRResult
// object corresponds to one page request, we are using it as a key to know.
const resultsWithHydrationScript = new WeakSet<SSRResult>();

function determineIfNeedsHydrationScript(result: SSRResult): boolean {
if (resultsWithHydrationScript.has(result)) {
return false;
}
resultsWithHydrationScript.add(result);
return true;
}

// INVESTIGATE:
// 2. Less anys when possible and make it well known when they are needed.
Expand Down Expand Up @@ -158,6 +152,7 @@ function formatList(values: string[]): string {
return `${values.slice(0, -1).join(', ')} or ${values[values.length - 1]}`;
}


export async function renderComponent(
result: SSRResult,
displayName: string,
Expand Down Expand Up @@ -191,6 +186,7 @@ export async function renderComponent(
const { hydration, props } = extractDirectives(_props);
let html = '';
let needsHydrationScript = hydration && determineIfNeedsHydrationScript(result);
let needsDirectiveScript = hydration && determinesIfNeedsDirectiveScript(result, hydration.directive);

if (hydration) {
metadata.hydrate = hydration.directive as AstroComponentMetadata['hydrate'];
Expand Down Expand Up @@ -348,19 +344,11 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr

island.children = `${html ?? ''}${template}`;

// Add the astro-island definition only once. Since the SSRResult object
// is scoped to a page renderer we can use it as a key to know if the script
// has been rendered or not.
let script = '';
if (needsHydrationScript) {
// Note that this is a class script, not a module script.
// This is so that it executes immediate, and when the browser encounters
// an astro-island element the callbacks will fire immediately, causing the JS
// deps to be loaded immediately.
script = `<script>${islandScript}</script>`;
}

return markHTMLString(script + renderElement('astro-island', island, false));
let prescriptType: PrescriptType = needsHydrationScript ? 'both' : needsDirectiveScript ?
'directive' : null;
let prescripts = getPrescripts(prescriptType, hydration.directive);

return markHTMLString(prescripts + renderElement('astro-island', island, false));
}

/** Create the Astro.fetchContent() runtime function. */
Expand Down
Loading

0 comments on commit 4916b73

Please sign in to comment.