From 655da1f9572b164e28d9b52b65ce05030d5494e0 Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Fri, 27 Sep 2024 00:45:37 -0400 Subject: [PATCH] Add support for waiting on streamed serializations --- .changeset/nice-shoes-enjoy.md | 5 + packages/browser/source/browser.ts | 152 +++++++++++++++++++++-------- 2 files changed, 118 insertions(+), 39 deletions(-) create mode 100644 .changeset/nice-shoes-enjoy.md diff --git a/.changeset/nice-shoes-enjoy.md b/.changeset/nice-shoes-enjoy.md new file mode 100644 index 000000000..f2ff1164f --- /dev/null +++ b/.changeset/nice-shoes-enjoy.md @@ -0,0 +1,5 @@ +--- +'@quilted/browser': patch +--- + +Add support for waiting on streamed serializations diff --git a/packages/browser/source/browser.ts b/packages/browser/source/browser.ts index ee8940686..74409a3d1 100644 --- a/packages/browser/source/browser.ts +++ b/packages/browser/source/browser.ts @@ -30,7 +30,7 @@ export class Browser implements BrowserDetails { } export class BrowserCookies implements Cookies { - private readonly cookieSignals = signal( + readonly #cookieSignals = signal( new Map>( Object.entries(JSCookie.get()).map(([cookie, value]) => [ cookie, @@ -40,25 +40,25 @@ export class BrowserCookies implements Cookies { ); has(cookie: string) { - return this.cookieSignals.value.get(cookie)?.value != null; + return this.#cookieSignals.value.get(cookie)?.value != null; } get(cookie: string) { - return this.cookieSignals.value.get(cookie)?.value; + return this.#cookieSignals.value.get(cookie)?.value; } set(cookie: string, value: string, options?: CookieOptions) { JSCookie.set(cookie, value, options); - this.updateCookie(cookie); + this.#updateCookie(cookie); } delete(cookie: string, options?: CookieOptions) { JSCookie.remove(cookie, options); - this.updateCookie(cookie); + this.#updateCookie(cookie); } *entries() { - const cookies = this.cookieSignals.peek(); + const cookies = this.#cookieSignals.peek(); for (const [cookie, signal] of cookies) { yield [cookie, signal.peek()] as const; @@ -66,12 +66,12 @@ export class BrowserCookies implements Cookies { } *[Symbol.iterator]() { - yield* this.cookieSignals.peek().keys(); + yield* this.#cookieSignals.peek().keys(); } - private updateCookie(cookie: string) { + #updateCookie(cookie: string) { const value = JSCookie.get(cookie); - const cookieSignals = this.cookieSignals.peek(); + const cookieSignals = this.#cookieSignals.peek(); const cookieSignal = cookieSignals.get(cookie); if (value) { @@ -81,26 +81,26 @@ export class BrowserCookies implements Cookies { const newCookie = signal(value); const newCookies = new Map(cookieSignals); newCookies.set(cookie, newCookie); - this.cookieSignals.value = newCookies; + this.#cookieSignals.value = newCookies; } } else if (cookieSignal) { const newCookies = new Map(cookieSignals); newCookies.delete(cookie); - this.cookieSignals.value = newCookies; + this.#cookieSignals.value = newCookies; } } } export class BrowserTitle { - private titleElement = document.head.querySelector('title'); - private titleValues = signal[]>([]); + #titleElement = document.head.querySelector('title'); + #titleValues = signal[]>([]); add = (title: string | ReadonlySignal) => { const titleSignal = isSignal(title) ? title : signal(title); - const newTitleValues = [...this.titleValues.peek(), titleSignal]; - this.titleValues.value = newTitleValues; + const newTitleValues = [...this.#titleValues.peek(), titleSignal]; + this.#titleValues.value = newTitleValues; return () => { - this.titleValues.value = this.titleValues.value.filter( + this.#titleValues.value = this.#titleValues.value.filter( (existingTitle) => existingTitle !== titleSignal, ); }; @@ -108,25 +108,25 @@ export class BrowserTitle { constructor() { effect(() => { - const title = this.titleValues.value.at(-1)?.value; + const title = this.#titleValues.value.at(-1)?.value; if (title == null) return; - if (this.titleElement) { - this.titleElement.textContent = title; + if (this.#titleElement) { + this.#titleElement.textContent = title; } else { - this.titleElement = document.createElement('title'); - this.titleElement.textContent = title; - document.head.appendChild(this.titleElement); + this.#titleElement = document.createElement('title'); + this.#titleElement.textContent = title; + document.head.appendChild(this.#titleElement); } }); } } export class BrowserHeadElements { - private initialElements: readonly HTMLElementTagNameMap[Element][]; + #initialElements: readonly HTMLElementTagNameMap[Element][]; constructor(readonly element: Element) { - this.initialElements = Array.from(document.head.querySelectorAll(element)); + this.#initialElements = Array.from(document.head.querySelectorAll(element)); } add = ( @@ -138,7 +138,7 @@ export class BrowserHeadElements { setAttributes(element, attributes); - const existingElement = this.initialElements.find((existingElement) => { + const existingElement = this.#initialElements.find((existingElement) => { return element.isEqualNode(existingElement); }); @@ -220,34 +220,108 @@ export class BrowserSerializationElement extends HTMLElement { } export class BrowserSerializations { - private readonly serializations = new Map( - Array.from( - document.querySelectorAll( - DEFAULT_SERIALIZATION_ELEMENT_NAME, - ), - ).map((node) => [ - node.getAttribute('name') ?? '_default', - getSerializedFromNode(node), - ]), + readonly #serializations = new Map( + getSerializationsFromDocument(), ); + #serializationResolvers = new Map void>>(); + #teardownMutationObserver: (() => void) | undefined; - get(id: string) { - return this.serializations.get(id) as any; + get(id: string) { + return this.#serializations.get(id) as T; } set(id: string, data: unknown) { if (data === undefined) { - this.serializations.delete(id); + this.#serializations.delete(id); } else { - this.serializations.set(id, data); + this.#serializations.set(id, data); + + if (this.#serializationResolvers.has(id)) { + for (const resolve of this.#serializationResolvers.get(id) ?? []) { + resolve(data); + } + + this.#serializationResolvers.delete(id); + if (this.#serializationResolvers.size === 0) { + this.#teardownMutationObserver?.(); + } + } + } + } + + update( + entries: Iterable<[string, unknown]> = getSerializationsFromDocument(), + ) { + for (const [id, data] of entries) { + this.set(id, data); + } + } + + waitFor(id: string): Promise { + if (this.#serializations.has(id)) { + return Promise.resolve(this.get(id)); } + + return new Promise((resolve) => { + this.#addResolver(id, resolve); + }); } *[Symbol.iterator]() { - yield* this.serializations; + yield* this.#serializations; + } + + #addResolver(id: string, resolver: (data: T) => void) { + const needsToStart = this.#serializationResolvers == null; + const resolvers = this.#serializationResolvers.get(id) ?? new Set(); + resolvers.add(resolver); + this.#serializationResolvers.set(id, resolvers); + + if (needsToStart) { + const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.update( + Array.from( + (node as Element).querySelectorAll( + DEFAULT_SERIALIZATION_ELEMENT_NAME, + ), + ).map(serializationEntryFromNode), + ); + } + } + } + } + }); + + this.#teardownMutationObserver = () => { + mutationObserver.disconnect(); + this.#teardownMutationObserver = undefined; + }; + + mutationObserver.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } } } +function getSerializationsFromDocument() { + return Array.from( + document.querySelectorAll(DEFAULT_SERIALIZATION_ELEMENT_NAME), + ).map(serializationEntryFromNode); +} + +function serializationEntryFromNode(node: Element): [string, T] { + return [ + node.getAttribute('name') ?? '_default', + getSerializedFromNode(node) as any, + ]; +} + function getSerializedFromNode(node: Element): T | undefined { const value = node.getAttribute('content');