From ab121cc0f9a0c2bfe51e9ec4452064076ea519ce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:34:17 +0000 Subject: [PATCH 1/3] feat(core)!: implement template loading state with pending/resolved status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SugarTemplateState type supporting pending and resolved states - Implement useIsPending hook for React components to track loading state - Update SugarInner to handle new template state structure - Modify useObject to propagate pending state to nested Sugar instances - Update useForm interface to accept new template format - Add comprehensive tests for template loading functionality - Update all existing tests to use new template format BREAKING CHANGE: Template type changed from T | undefined to SugarTemplateState which supports { status: 'pending' } | { status: 'resolved', value: T } | undefined Co-Authored-By: あすぱる --- packages/core/src/form/index.ts | 9 +- packages/core/src/lib.ts | 2 +- packages/core/src/sugar/index.ts | 26 ++++- packages/core/src/sugar/types.ts | 8 ++ packages/core/src/sugar/useIsPending.ts | 28 +++++ packages/core/src/sugar/useObject.ts | 53 ++++++++- tests/core-unittest/src/collect.spec.tsx | 10 +- tests/core-unittest/src/component.spec.tsx | 8 +- .../src/destroyBeforeReady.spec.tsx | 4 +- tests/core-unittest/src/setTemplate.spec.tsx | 47 +++++--- tests/core-unittest/src/useIsPending.spec.tsx | 107 ++++++++++++++++++ tests/sandbox/src/App.tsx | 90 ++++++++++++--- 12 files changed, 345 insertions(+), 47 deletions(-) create mode 100644 packages/core/src/sugar/useIsPending.ts create mode 100644 tests/core-unittest/src/useIsPending.spec.tsx diff --git a/packages/core/src/form/index.ts b/packages/core/src/form/index.ts index ab60b2d..9627dd1 100644 --- a/packages/core/src/form/index.ts +++ b/packages/core/src/form/index.ts @@ -1,6 +1,11 @@ import { useRef } from 'react'; import { SugarInner } from '../sugar'; -import { Sugar, SugarValue, SugarGetResult } from '../sugar/types'; +import { + Sugar, + SugarValue, + SugarGetResult, + SugarTemplateState, +} from '../sugar/types'; export interface UseFormResult { sugar: Sugar; @@ -10,7 +15,7 @@ export interface UseFormResult { export const useForm = ({ template, }: { - template?: T; + template?: SugarTemplateState; } = {}): UseFormResult => { const sugar = useRef>(undefined); if (!sugar.current) { diff --git a/packages/core/src/lib.ts b/packages/core/src/lib.ts index a1ff34a..07b9fc6 100644 --- a/packages/core/src/lib.ts +++ b/packages/core/src/lib.ts @@ -2,4 +2,4 @@ export { useForm } from './form'; export type { UseFormResult } from './form'; export { TextInput } from './components/textInput'; export { NumberInput } from './components/numberInput'; -export type { Sugar } from './sugar/types'; +export type { Sugar, SugarTemplateState } from './sugar/types'; diff --git a/packages/core/src/sugar/index.ts b/packages/core/src/sugar/index.ts index 8b6ed3c..a1b45ef 100644 --- a/packages/core/src/sugar/index.ts +++ b/packages/core/src/sugar/index.ts @@ -7,6 +7,7 @@ import { SugarSetResult, SugarSetter, SugarTemplateSetter, + SugarTemplateState, SugarValue, SugarValueObject, } from './types'; @@ -17,6 +18,7 @@ import { ValidationStage, FailFn, } from './useValidation'; +import { useIsPending, SugarUseIsPending } from './useIsPending'; export class SugarInner { // Sugarは、get/setができるようになるまでに、Reactのレンダリングを待つ必要があります。 @@ -63,12 +65,12 @@ export class SugarInner { status: 'unavailable'; }; - template: T | undefined; + template: SugarTemplateState; private validators: Set< (stage: ValidationStage, value: T) => Promise > = new Set(); - constructor(template?: T) { + constructor(template?: SugarTemplateState) { const { promise: getPromise, resolve: resolveGetPromise } = Promise.withResolvers>(); const { promise: setPromise, resolve: resolveSetPromise } = @@ -155,7 +157,8 @@ export class SugarInner { } setTemplate(value: T, executeSet = true): Promise> { - this.template = value; + this.template = { status: 'resolved', value }; + this.dispatchEvent('templateChange'); switch (this.status.status) { case 'unavailable': @@ -181,6 +184,18 @@ export class SugarInner { } } + setPendingTemplate(): void { + this.template = { status: 'pending' }; + this.dispatchEvent('templateChange'); + } + + private getTemplateValue(): T | undefined { + if (this.template?.status === 'resolved') { + return this.template.value; + } + return undefined; + } + private eventTarget: EventTarget = new EventTarget(); addEventListener( @@ -214,7 +229,7 @@ export class SugarInner { const status = this.status; status.lock = true; - const initial = status.recentValue ?? this.template; + const initial = status.recentValue ?? this.getTemplateValue(); if (initial !== undefined) { status.resolveSetPromise(await setter(initial)); } @@ -273,4 +288,7 @@ export class SugarInner { deps?: React.DependencyList ) => useValidation(this as Sugar, validator, deps)) as SugarUseValidation; + + useIsPending: SugarUseIsPending = (() => + useIsPending(this as Sugar)) as SugarUseIsPending; } diff --git a/packages/core/src/sugar/types.ts b/packages/core/src/sugar/types.ts index 9b8c3b7..1d6fa02 100644 --- a/packages/core/src/sugar/types.ts +++ b/packages/core/src/sugar/types.ts @@ -1,6 +1,11 @@ export type SugarValue = unknown; export type SugarValueObject = SugarValue & Record; +export type SugarTemplateState = + | { status: 'pending' } + | { status: 'resolved'; value: T } + | undefined; + export type SugarGetResult = | { result: 'success'; @@ -34,6 +39,7 @@ export type SugarTemplateSetter = ( import type { SugarUseObject } from './useObject'; import type { SugarUseValidation } from './useValidation'; +import type { SugarUseIsPending } from './useIsPending'; type SugarType = { get: SugarGetter; @@ -47,6 +53,7 @@ type SugarType = { destroy: () => void; useObject: SugarUseObject; useValidation: SugarUseValidation; + useIsPending: SugarUseIsPending; addEventListener: ( type: K, listener: CustomEventListener @@ -75,4 +82,5 @@ export type SugarEvent = { change: undefined; blur: undefined; submit: undefined; + templateChange: undefined; }; diff --git a/packages/core/src/sugar/useIsPending.ts b/packages/core/src/sugar/useIsPending.ts new file mode 100644 index 0000000..b5184cc --- /dev/null +++ b/packages/core/src/sugar/useIsPending.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { SugarInner } from '.'; +import { Sugar, SugarValue } from './types'; + +export type SugarUseIsPending = () => boolean; + +export function useIsPending(sugar: Sugar): boolean { + const [isPending, setIsPending] = useState(() => { + const sugarInner = sugar as unknown as SugarInner; + return sugarInner.template?.status === 'pending'; + }); + + useEffect(() => { + const updatePendingState = () => { + const sugarInner = sugar as unknown as SugarInner; + const newIsPending = sugarInner.template?.status === 'pending'; + setIsPending(newIsPending); + }; + + sugar.addEventListener('templateChange', updatePendingState); + + return () => { + sugar.removeEventListener('templateChange', updatePendingState); + }; + }, [sugar]); + + return isPending; +} diff --git a/packages/core/src/sugar/useObject.ts b/packages/core/src/sugar/useObject.ts index fd0696b..271d3a5 100644 --- a/packages/core/src/sugar/useObject.ts +++ b/packages/core/src/sugar/useObject.ts @@ -3,6 +3,7 @@ import { Sugar, SugarGetResult, SugarSetResult, + SugarTemplateState, SugarValue, SugarValueObject, } from './types'; @@ -36,7 +37,33 @@ export function useObject( { get: (target: Record>, prop: string, _) => { if (!(prop in target)) { - const s = new SugarInner((sugar as SugarInner).template?.[prop]); + const parentTemplate = (sugar as SugarInner).template; + let childTemplate: SugarTemplateState; + + if (parentTemplate?.status === 'pending') { + childTemplate = { status: 'pending' }; + } else if (parentTemplate?.status === 'resolved') { + const parentValue = parentTemplate.value as Record< + string, + unknown + >; + if ( + parentValue && + typeof parentValue === 'object' && + prop in parentValue + ) { + childTemplate = { + status: 'resolved', + value: parentValue[prop], + }; + } else { + childTemplate = undefined; + } + } else { + childTemplate = undefined; + } + + const s = new SugarInner(childTemplate); sugarInitializer.current.forEach((initializer) => { s.addEventListener('change', initializer.dispatchChange); s.addEventListener('blur', initializer.dispatchBlur); @@ -59,10 +86,31 @@ export function useObject( dispatchBlur, }; + const onTemplateChange = () => { + const parentTemplate = (sugar as SugarInner).template; + Object.entries(fields.current!).forEach(([key, childSugar]) => { + const childSugarInner = childSugar as SugarInner; + if (parentTemplate?.status === 'pending') { + childSugarInner.setPendingTemplate(); + } else if (parentTemplate?.status === 'resolved') { + const parentValue = parentTemplate.value as Record; + if ( + parentValue && + typeof parentValue === 'object' && + key in parentValue + ) { + childSugarInner.setTemplate(parentValue[key], false); + } + } + }); + }; + Object.values(fields.current!).forEach((sugar) => { sugar.addEventListener('change', dispatchChange); sugar.addEventListener('blur', dispatchBlur); }); + + sugar.addEventListener('templateChange', onTemplateChange); sugarInitializer.current.push(initializer); sugar.ready( @@ -189,9 +237,10 @@ export function useObject( sugar.destroy(); if (fields.current) { Object.values(fields.current).forEach((sugar) => { - sugar.removeEventListener('change', dispatchEvent); + sugar.removeEventListener('change', dispatchChange); sugar.removeEventListener('blur', dispatchBlur); }); + sugar.removeEventListener('templateChange', onTemplateChange); sugarInitializer.current = sugarInitializer.current.filter( (i) => i !== initializer ); diff --git a/tests/core-unittest/src/collect.spec.tsx b/tests/core-unittest/src/collect.spec.tsx index 40fd27c..62f88dc 100644 --- a/tests/core-unittest/src/collect.spec.tsx +++ b/tests/core-unittest/src/collect.spec.tsx @@ -6,7 +6,7 @@ import { describeWithStrict } from '../util/describeWithStrict'; describeWithStrict('useForm#collect', () => { test('collect method should be equivalent to sugar.get(true)', async () => { const { result } = renderHook(() => - useForm({ template: 'initial' }) + useForm({ template: { status: 'resolved', value: 'initial' } }) ); render(); @@ -23,7 +23,9 @@ describeWithStrict('useForm#collect', () => { }); test('collect method should trigger validation like sugar.get(true)', async () => { - const { result } = renderHook(() => useForm({ template: { a: '' } })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: { a: '' } } }) + ); const { result: obj } = renderHook(() => result.current.sugar.useObject()); const validate = async ( @@ -48,8 +50,8 @@ describeWithStrict('useForm#collect', () => { test('collect method should return the same type as sugar.get(true)', async () => { const { result } = renderHook(() => - useForm<{ name: string; age: number }>({ - template: { name: 'John', age: 25 }, + useForm({ + template: { status: 'resolved', value: { name: 'John', age: 25 } }, }) ); const { result: obj } = renderHook(() => result.current.sugar.useObject()); diff --git a/tests/core-unittest/src/component.spec.tsx b/tests/core-unittest/src/component.spec.tsx index 07d0507..57484e4 100644 --- a/tests/core-unittest/src/component.spec.tsx +++ b/tests/core-unittest/src/component.spec.tsx @@ -22,7 +22,9 @@ type Component = { describeWithStrict('Component requirements', () => { describe.each(Components)('$name', (c) => { test('Component should be ready after render', async () => { - const { result } = renderHook(() => useForm({ template: c.template })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: c.template } }) + ); const get = result.current.sugar.get(); expect(await checkPending(get)).toStrictEqual({ resolved: false }); @@ -39,7 +41,9 @@ describeWithStrict('Component requirements', () => { }); test('Sugar should be destroyed after unmount', async () => { - const { result } = renderHook(() => useForm({ template: c.template })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: c.template } }) + ); const { unmount } = render(); await expect(result.current.sugar.get()).resolves.toStrictEqual({ result: 'success', diff --git a/tests/core-unittest/src/destroyBeforeReady.spec.tsx b/tests/core-unittest/src/destroyBeforeReady.spec.tsx index c01e079..b9c17d2 100644 --- a/tests/core-unittest/src/destroyBeforeReady.spec.tsx +++ b/tests/core-unittest/src/destroyBeforeReady.spec.tsx @@ -6,7 +6,9 @@ import { checkPending } from '../util/checkPending'; describeWithStrict('Sugar#destroy before ready', () => { test('destroy resolves pending promises', async () => { - const { result } = renderHook(() => useForm({ template: '' })); + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: '' } }) + ); const getPromise = result.current.sugar.get(); const setPromise = result.current.sugar.set('test'); diff --git a/tests/core-unittest/src/setTemplate.spec.tsx b/tests/core-unittest/src/setTemplate.spec.tsx index a1a3f7e..4e59c9e 100644 --- a/tests/core-unittest/src/setTemplate.spec.tsx +++ b/tests/core-unittest/src/setTemplate.spec.tsx @@ -7,7 +7,7 @@ import { SugarInner } from '../../../packages/core/src/sugar/index'; describeWithStrict('Sugar#setTemplate', () => { test('setTemplate(value, true) updates template and executes set (default behavior)', async () => { const { result } = renderHook(() => - useForm({ template: 'original' }) + useForm({ template: { status: 'resolved', value: 'original' } }) ); render(); @@ -21,14 +21,17 @@ describeWithStrict('Sugar#setTemplate', () => { value: 'new template', }); - expect((result.current.sugar as SugarInner).template).toBe( - 'new template' + expect((result.current.sugar as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new template', + } ); }); test('setTemplate(value, false) updates template only without executing set', async () => { const { result } = renderHook(() => - useForm({ template: 'original' }) + useForm({ template: { status: 'resolved', value: 'original' } }) ); render(); @@ -43,14 +46,17 @@ describeWithStrict('Sugar#setTemplate', () => { value: 'current value', }); - expect((result.current.sugar as SugarInner).template).toBe( - 'new template' + expect((result.current.sugar as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new template', + } ); }); test('setTemplate without executeSet parameter defaults to true', async () => { const { result } = renderHook(() => - useForm({ template: 'original' }) + useForm({ template: { status: 'resolved', value: 'original' } }) ); render(); @@ -64,14 +70,19 @@ describeWithStrict('Sugar#setTemplate', () => { value: 'new template', }); - expect((result.current.sugar as SugarInner).template).toBe( - 'new template' + expect((result.current.sugar as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new template', + } ); }); test('setTemplate works with nested objects', async () => { const { result } = renderHook(() => - useForm({ template: { a: 'initial', b: 'initial' } }) + useForm({ + template: { status: 'resolved', value: { a: 'initial', b: 'initial' } }, + }) ); const { result: obj } = renderHook(() => result.current.sugar.useObject()); @@ -93,9 +104,19 @@ describeWithStrict('Sugar#setTemplate', () => { expect( (result.current.sugar as SugarInner<{ a: string; b: string }>).template - ).toStrictEqual({ a: 'new-a', b: 'new-b' }); + ).toStrictEqual({ status: 'resolved', value: { a: 'new-a', b: 'new-b' } }); - expect((obj.current.fields.a as SugarInner).template).toBe('new-a'); - expect((obj.current.fields.b as SugarInner).template).toBe('new-b'); + expect((obj.current.fields.a as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new-a', + } + ); + expect((obj.current.fields.b as SugarInner).template).toStrictEqual( + { + status: 'resolved', + value: 'new-b', + } + ); }); }); diff --git a/tests/core-unittest/src/useIsPending.spec.tsx b/tests/core-unittest/src/useIsPending.spec.tsx new file mode 100644 index 0000000..0104347 --- /dev/null +++ b/tests/core-unittest/src/useIsPending.spec.tsx @@ -0,0 +1,107 @@ +import { TextInput, useForm } from '@sugarform/core'; +import { renderHook, render, act } from '@testing-library/react'; +import { expect, test } from 'vitest'; +import { describeWithStrict } from '../util/describeWithStrict'; + +describeWithStrict('Sugar#useIsPending', () => { + test('returns false when template is resolved', async () => { + const { result } = renderHook(() => + useForm({ template: { status: 'resolved', value: 'test' } }) + ); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + expect(isPendingResult.current).toBe(false); + }); + + test('returns true when template is pending', async () => { + const { result } = renderHook(() => + useForm({ template: { status: 'pending' } }) + ); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + expect(isPendingResult.current).toBe(true); + }); + + test('returns false when template is undefined', async () => { + const { result } = renderHook(() => useForm({ template: undefined })); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + expect(isPendingResult.current).toBe(false); + }); + + test('updates when template changes from pending to resolved', async () => { + const { result } = renderHook(() => + useForm({ template: { status: 'pending' } }) + ); + + const { result: isPendingResult } = renderHook(() => + result.current.sugar.useIsPending() + ); + + render(); + await act(async () => {}); + + expect(isPendingResult.current).toBe(true); + + await act(async () => { + await result.current.sugar.setTemplate('resolved value'); + }); + + expect(isPendingResult.current).toBe(false); + }); + + test('propagates pending state to nested objects', async () => { + const { result } = renderHook(() => + useForm>({ template: { status: 'pending' } }) + ); + const { result: obj } = renderHook(() => result.current.sugar.useObject()); + + const { result: childIsPendingResult } = renderHook(() => + obj.current.fields.a!.useIsPending() + ); + + expect(childIsPendingResult.current).toBe(true); + }); + + test('updates nested objects when parent template changes', async () => { + const { result } = renderHook(() => + useForm>({ template: { status: 'pending' } }) + ); + const { result: obj } = renderHook(() => result.current.sugar.useObject()); + + render( + <> + {/* Fields 'a' and 'b' are guaranteed to exist in the proxy object */} + + + + ); + await act(async () => {}); + + const { result: childAIsPendingResult } = renderHook(() => + obj.current.fields.a!.useIsPending() + ); + const { result: childBIsPendingResult } = renderHook(() => + obj.current.fields.b!.useIsPending() + ); + + expect(childAIsPendingResult.current).toBe(true); + expect(childBIsPendingResult.current).toBe(true); + + await act(async () => { + await result.current.sugar.setTemplate({ a: 'value-a', b: 'value-b' }); + }); + + expect(childAIsPendingResult.current).toBe(false); + expect(childBIsPendingResult.current).toBe(false); + }); +}); diff --git a/tests/sandbox/src/App.tsx b/tests/sandbox/src/App.tsx index ea36655..1a9be32 100644 --- a/tests/sandbox/src/App.tsx +++ b/tests/sandbox/src/App.tsx @@ -1,7 +1,13 @@ -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; // Demo showing how validation errors appear in real usage import './App.css'; -import { useForm, TextInput, NumberInput, type Sugar } from '@sugarform/core'; +import { + useForm, + TextInput, + NumberInput, + type Sugar, + type SugarTemplateState, +} from '@sugarform/core'; type Birthday = { year: number; @@ -21,36 +27,74 @@ type FormType = { }; function App() { + const [isLoading, setIsLoading] = useState(true); + + const initialTemplate: SugarTemplateState = isLoading + ? { status: 'pending' } + : { + status: 'resolved', + value: { + person_a: { + firstName: 'Alice', + lastName: 'Smith', + birthday: { year: 2000, month: 1, day: 1 }, + }, + person_b: { + firstName: 'Bob', + lastName: 'Johnson', + birthday: { year: 2000, month: 1, day: 1 }, + }, + }, + }; + const { sugar, collect } = useForm({ - template: { - person_a: { - firstName: 'Alice', - lastName: 'Smith', - birthday: { year: 2000, month: 1, day: 1 }, - }, - person_b: { - firstName: 'Bob', - lastName: 'Johnson', - birthday: { year: 2000, month: 1, day: 1 }, - }, - }, + template: initialTemplate, }); const { fields } = sugar.useObject(); + const isPending = sugar.useIsPending(); return ( <>

Hello, Sugarform!

-

Person A

- -

Person B

- + {isPending ? ( +
Loading template...
+ ) : ( + <> +

Person A

+ +

Person B

+ + + )} + @@ -60,6 +104,11 @@ function App() { function PersonInput({ sugar }: { sugar: Sugar }) { const { fields } = sugar.useObject(); + const isPending = sugar.useIsPending(); + + if (isPending) { + return
Loading person data...
; + } return (
@@ -78,6 +127,7 @@ function PersonInput({ sugar }: { sugar: Sugar }) { function BirthdayInput({ sugar }: { sugar: Sugar }) { const { fields } = sugar.useObject(); + const isPending = sugar.useIsPending(); const errors = sugar.useValidation( useCallback(async (value, fail) => { @@ -107,6 +157,10 @@ function BirthdayInput({ sugar }: { sugar: Sugar }) { }, []) ); + if (isPending) { + return
Loading birthday data...
; + } + return (