From 3a1fb35f5a35ca5443b6b7cb243ad37349e94415 Mon Sep 17 00:00:00 2001 From: Robby6Strings Date: Mon, 28 Oct 2024 19:41:22 +1300 Subject: [PATCH] lib, vpk - improve HMR for signals, context and FCs --- packages/lib/src/constants.ts | 1 + packages/lib/src/context.ts | 34 +++++++++++++++++++- packages/lib/src/hmr.ts | 17 +++++++--- packages/lib/src/reconciler.ts | 7 ++-- packages/lib/src/signals/base.ts | 32 ++++++++++++------ packages/lib/src/types.ts | 3 +- packages/lib/src/utils.ts | 16 +++++++++ packages/vite-plugin-kaioken/src/index.ts | 11 +++++++ sandbox/csr/src/App.tsx | 1 - sandbox/ssr/src/components/Counter.tsx | 4 +-- sandbox/ssr/src/pages/counter/+Page.tsx | 9 +++--- sandbox/ssr/src/pages/index/+Page.tsx | 5 ++- sandbox/ssr/src/pages/products/+Page.tsx | 2 +- sandbox/ssr/src/renderer/+onRenderClient.tsx | 12 ++++--- 14 files changed, 123 insertions(+), 31 deletions(-) diff --git a/packages/lib/src/constants.ts b/packages/lib/src/constants.ts index b4ce3703..a520e15d 100644 --- a/packages/lib/src/constants.ts +++ b/packages/lib/src/constants.ts @@ -1,4 +1,5 @@ export const $SIGNAL = Symbol.for("kaioken.signal") +export const $CONTEXT = Symbol.for("kaioken.context") export const $CONTEXT_PROVIDER = Symbol.for("kaioken.contextProvider") export const $FRAGMENT = Symbol.for("kaioken.fragment") export const $KAIOKEN_ERROR = Symbol.for("kaioken.error") diff --git a/packages/lib/src/context.ts b/packages/lib/src/context.ts index 86ba91ba..0cbb3f09 100644 --- a/packages/lib/src/context.ts +++ b/packages/lib/src/context.ts @@ -1,8 +1,12 @@ -import { $CONTEXT_PROVIDER } from "./constants.js" +import { $CONTEXT, $CONTEXT_PROVIDER, $HMR_ACCEPT } from "./constants.js" import { createElement } from "./element.js" +import { __DEV__ } from "./env.js" +import { GenericHMRAcceptor } from "./hmr.js" +import { traverseApply } from "./utils.js" export function createContext(defaultValue: T): Kaioken.Context { const ctx: Kaioken.Context = { + [$CONTEXT]: true, Provider: ({ value, children }: Kaioken.ProviderProps) => { return createElement( $CONTEXT_PROVIDER, @@ -18,5 +22,33 @@ export function createContext(defaultValue: T): Kaioken.Context { return this.Provider.displayName || "Anonymous Context" }, } + if (__DEV__) { + const asHmrAcceptor = ctx as any as GenericHMRAcceptor> + asHmrAcceptor[$HMR_ACCEPT] = { + inject: (prev) => { + const newProvider = ctx.Provider + window.__kaioken!.apps.forEach((ctx) => { + if (!ctx.mounted || !ctx.rootNode) return + traverseApply(ctx.rootNode, (vNode) => { + if (vNode.type === prev.Provider) { + vNode.type = newProvider + vNode.hmrUpdated = true + if (vNode.prev) { + vNode.prev.type = newProvider + } + ctx.requestUpdate(vNode) + } + }) + }) + }, + destroy: () => {}, + provide: () => ctx, + } + } + return ctx } + +export function isContext(thing: unknown): thing is Kaioken.Context { + return typeof thing === "object" && !!thing && $CONTEXT in thing +} diff --git a/packages/lib/src/hmr.ts b/packages/lib/src/hmr.ts index 04676233..305f30f6 100644 --- a/packages/lib/src/hmr.ts +++ b/packages/lib/src/hmr.ts @@ -15,7 +15,7 @@ export type GenericHMRAcceptor = { [$HMR_ACCEPT]: HMRAccept } -type HotVar = Kaioken.FC | Store | Signal +type HotVar = Kaioken.FC | Store | Signal | Kaioken.Context export function isGenericHmrAcceptor( thing: unknown @@ -38,7 +38,6 @@ type ModuleMemory = { export function createHMRContext() { type FilePath = string const moduleMap = new Map() - let currentModuleMemory: ModuleMemory | null = null let isModuleReplacementExecution = false const isReplacement = () => isModuleReplacementExecution @@ -65,8 +64,18 @@ export function createHMRContext() { for (const [name, newVar] of Object.entries(hotVars)) { const oldVar = currentModuleMemory.hotVars.get(name) - // @ts-ignore - newVar.__devtoolsFileLink = currentModuleMemory.fileLink + ":0" + if (typeof newVar === "function") { + // @ts-ignore - this is how we tell devtools what file the component is from + newVar.__devtoolsFileLink = currentModuleMemory.fileLink + ":0" + if (oldVar) { + /** + * this is how, when the previous function has been stored somewhere else (eg. by Vike), + * we can trace it to its latest version + */ + // @ts-ignore + oldVar.__next = newVar + } + } currentModuleMemory.hotVars.set(name, newVar) if (!oldVar) continue if (isGenericHmrAcceptor(oldVar) && isGenericHmrAcceptor(newVar)) { diff --git a/packages/lib/src/reconciler.ts b/packages/lib/src/reconciler.ts index f1688984..faa30920 100644 --- a/packages/lib/src/reconciler.ts +++ b/packages/lib/src/reconciler.ts @@ -1,7 +1,7 @@ import type { AppContext } from "./appContext" import { ELEMENT_TYPE, FLAG, $FRAGMENT } from "./constants.js" import { ctx } from "./globals.js" -import { isVNode } from "./utils.js" +import { isVNode, latest } from "./utils.js" import { Signal } from "./signals/base.js" import { __DEV__ } from "./env.js" import { createElement, Fragment } from "./element.js" @@ -215,7 +215,10 @@ function updateTextNode( } function updateNode(parent: VNode, oldNode: VNode | null, newNode: VNode) { - const nodeType = newNode.type + let nodeType = newNode.type + if (typeof nodeType === "function") { + nodeType = latest(nodeType) + } if (nodeType === $FRAGMENT) { return updateFragment( parent, diff --git a/packages/lib/src/signals/base.ts b/packages/lib/src/signals/base.ts index ba7f2223..6b9069f5 100644 --- a/packages/lib/src/signals/base.ts +++ b/packages/lib/src/signals/base.ts @@ -3,6 +3,7 @@ import { __DEV__ } from "../env.js" import type { HMRAccept } from "../hmr.js" import { getVNodeAppContext, + latest, safeStringify, sideEffectsEnabled, } from "../utils.js" @@ -18,6 +19,7 @@ export class Signal { protected $id: string protected $value: T protected $initialValue?: string + protected __next?: Signal constructor(initial: T, displayName?: string) { this.$id = crypto.randomUUID() signalSubsMap.set(this.$id, new Set()) @@ -38,6 +40,9 @@ export class Signal { signalSubsMap.get(this.$id)?.clear?.() signalSubsMap.delete(this.$id) this.$id = prev.$id + // @ts-ignore - this handles scenarios where a reference to the prev has been encapsulated + // and we need to be able to refer to the latest version of the signal. + prev.__next = this }, destroy: () => {}, } satisfies HMRAccept> @@ -45,27 +50,32 @@ export class Signal { } get value() { - Signal.entangle(this) - return this.$value + const tgt = latest(this) + Signal.entangle(tgt) + return tgt.$value } set value(next: T) { - if (Object.is(this.$value, next)) return - this.$value = next - this.notify() + const tgt = latest(this) + if (Object.is(tgt.$value, next)) return + tgt.$value = next + tgt.notify() } peek() { - return this.$value + const tgt = latest(this) + return tgt.$value } sneak(newValue: T) { - this.$value = newValue + const tgt = latest(this) + tgt.$value = newValue } toString() { - Signal.entangle(this) - return `${this.$value}` + const tgt = latest(this) + Signal.entangle(tgt) + return `${tgt.$value}` } subscribe(cb: (state: T) => void): () => void { @@ -75,10 +85,11 @@ export class Signal { } notify(options?: { filter?: (sub: Function | Kaioken.VNode) => boolean }) { + const tgt = latest(this) signalSubsMap.get(this.$id)?.forEach((sub) => { if (options?.filter && !options.filter(sub)) return if (typeof sub === "function") { - return sub(this.$value) + return sub(tgt.$value) } getVNodeAppContext(sub).requestUpdate(sub) }) @@ -173,6 +184,7 @@ export const useSignal = (initial: T, displayName?: string) => { }, } if (hook.signal && vNode.hmrUpdated) { + console.log("signal hook hmr updated (initial changed)") hook.signal.value = initial } } diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index 1c47889d..3476a0f7 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -3,7 +3,7 @@ import type { Signal as SignalClass, SignalLike, } from "./signals" -import type { $CONTEXT_PROVIDER, $FRAGMENT } from "./constants" +import type { $CONTEXT, $CONTEXT_PROVIDER, $FRAGMENT } from "./constants" import type { KaiokenGlobalContext } from "./globalContext" import type { EventAttributes, @@ -130,6 +130,7 @@ declare global { children?: JSX.Children | ((value: T) => JSX.Element) } type Context = { + [$CONTEXT]: true Provider: (({ value, children }: ProviderProps) => JSX.Element) & { displayName?: string } diff --git a/packages/lib/src/utils.ts b/packages/lib/src/utils.ts index 03f76c0f..8a4a2c55 100644 --- a/packages/lib/src/utils.ts +++ b/packages/lib/src/utils.ts @@ -4,6 +4,7 @@ import { unwrap } from "./signals/utils.js" import { KaiokenError } from "./error.js" import type { AppContext } from "./appContext" import type { ExoticVNode } from "./types.utils" +import { __DEV__ } from "./env.js" export { isVNode, @@ -25,6 +26,7 @@ export { sideEffectsEnabled, encodeHtmlEntities, noop, + latest, propFilters, selfClosingTags, svgTags, @@ -36,6 +38,20 @@ type VNode = Kaioken.VNode const noop: () => void = Object.freeze(() => {}) +/** + * This is a no-op in production. It is used to get the latest + * iteration of a component or signal after HMR has happened. + */ +function latest(thing: T): T { + let tgt: any = thing + if (__DEV__) { + while ("__next" in tgt) { + tgt = tgt.__next as typeof tgt + } + } + return tgt +} + /** * Returns true if called during DOM or hydration render mode. */ diff --git a/packages/vite-plugin-kaioken/src/index.ts b/packages/vite-plugin-kaioken/src/index.ts index c009f34e..5391b373 100644 --- a/packages/vite-plugin-kaioken/src/index.ts +++ b/packages/vite-plugin-kaioken/src/index.ts @@ -226,6 +226,10 @@ function findHotVars(nodes: AstNode[], _id: string): string[] { createAliasBuilder("kaioken", "computed") const { addAliases: addWatchAliases, nodeContainsAliasCall: isWatch } = createAliasBuilder("kaioken", "watch") + const { + addAliases: addCreateContextAliases, + nodeContainsAliasCall: isContext, + } = createAliasBuilder("kaioken", "createContext") for (const node of nodes) { if (node.type === "ImportDeclaration") { @@ -233,6 +237,7 @@ function findHotVars(nodes: AstNode[], _id: string): string[] { addSignalAliases(node) addComputedAliases(node) addWatchAliases(node) + addCreateContextAliases(node) continue } @@ -245,6 +250,7 @@ function findHotVars(nodes: AstNode[], _id: string): string[] { addHotVarNames(node, hotVarNames) continue } + if (findNode(node, isSignal)) { addHotVarNames(node, hotVarNames) continue @@ -258,6 +264,11 @@ function findHotVars(nodes: AstNode[], _id: string): string[] { if (findNode(node, isWatch)) { addHotVarNames(node, hotVarNames) } + + if (findNode(node, isContext)) { + addHotVarNames(node, hotVarNames) + continue + } } return Array.from(hotVarNames) } diff --git a/sandbox/csr/src/App.tsx b/sandbox/csr/src/App.tsx index 730d8e32..ac72c564 100644 --- a/sandbox/csr/src/App.tsx +++ b/sandbox/csr/src/App.tsx @@ -7,7 +7,6 @@ import { useCallback, useComputed, useSignal, - useWatch, signal, watch, } from "kaioken" diff --git a/sandbox/ssr/src/components/Counter.tsx b/sandbox/ssr/src/components/Counter.tsx index 0815b1e8..dec75e37 100644 --- a/sandbox/ssr/src/components/Counter.tsx +++ b/sandbox/ssr/src/components/Counter.tsx @@ -1,8 +1,8 @@ -import { signal } from "kaioken" +import { signal, useSignal } from "kaioken" export default function Counter() { console.log("Counter") - const count = signal(0) + const count = useSignal(0) return ( <> diff --git a/sandbox/ssr/src/pages/counter/+Page.tsx b/sandbox/ssr/src/pages/counter/+Page.tsx index 9b56717d..0ab45692 100644 --- a/sandbox/ssr/src/pages/counter/+Page.tsx +++ b/sandbox/ssr/src/pages/counter/+Page.tsx @@ -1,14 +1,15 @@ import Counter from "$/components/Counter" import { PageTitle } from "$/components/PageTitle" -import { computed, signal, useEffect } from "kaioken" +import { computed, signal, useComputed, useEffect, useSignal } from "kaioken" export { Page } +const id = signal(23) + function Page() { - const id = signal(0) - const divId = computed(() => id.value.toString(), "divId") + const divId = useComputed(() => `counter-${id}`, "divId") useEffect(() => { - const interval = setInterval(() => (id.value += 2), 1000) + const interval = setInterval(() => (id.value += 1), 1000) return () => clearInterval(interval) }, []) diff --git a/sandbox/ssr/src/pages/index/+Page.tsx b/sandbox/ssr/src/pages/index/+Page.tsx index be1dcead..eb76d6c8 100644 --- a/sandbox/ssr/src/pages/index/+Page.tsx +++ b/sandbox/ssr/src/pages/index/+Page.tsx @@ -1,7 +1,10 @@ +import { usePageContext } from "$/context/pageContext" + export function Page() { + const { isClient } = usePageContext() return (
-

Hello, world!

+

Hello, world! isClient: {`${isClient}`}

) } diff --git a/sandbox/ssr/src/pages/products/+Page.tsx b/sandbox/ssr/src/pages/products/+Page.tsx index f86c659f..8db53276 100644 --- a/sandbox/ssr/src/pages/products/+Page.tsx +++ b/sandbox/ssr/src/pages/products/+Page.tsx @@ -4,7 +4,7 @@ import type { ServerProps } from "./+data" export function Page({ products }: ServerProps) { return ( <> - Products + Product 123s
{products.map((product) => ( diff --git a/sandbox/ssr/src/renderer/+onRenderClient.tsx b/sandbox/ssr/src/renderer/+onRenderClient.tsx index 87181670..cad7d264 100644 --- a/sandbox/ssr/src/renderer/+onRenderClient.tsx +++ b/sandbox/ssr/src/renderer/+onRenderClient.tsx @@ -5,16 +5,20 @@ import type { AppContext } from "kaioken" import { getTitle } from "./utils" import { App } from "./App" -let appContext: AppContext<{ pageContext: PageContextClient }> | undefined +declare global { + interface Window { + __appContext: AppContext<{ pageContext: PageContextClient }> | undefined + } +} export const onRenderClient: OnRenderClientAsync = async (pageContext) => { const container = document.getElementById("page-root")! - if (pageContext.isHydration || !appContext) { - appContext = await hydrate(App, container, { pageContext }) + if (pageContext.isHydration || !window.__appContext) { + window.__appContext = await hydrate(App, container, { pageContext }) return } document.title = getTitle(pageContext) - await appContext.setProps(() => ({ pageContext })) + await window.__appContext.setProps(() => ({ pageContext })) }