@typed/navigation 0.17.0 → 0.18.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/src/Navigation.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * @since 1.0.0
3
3
  */
4
4
 
5
+ import type { HttpClientError, HttpClientResponse } from "@effect/platform"
5
6
  import type * as HttpClient from "@effect/platform/HttpClient"
6
7
  import { ParseResult } from "@effect/schema"
7
8
  import * as Equivalence from "@effect/schema/Equivalence"
@@ -68,9 +69,9 @@ export interface Navigation {
68
69
  data: FormData,
69
70
  formInput?: Simplify<Omit<FormInputFrom, "data">>
70
71
  ) => Effect.Effect<
71
- Option.Option<HttpClient.response.ClientResponse>,
72
- NavigationError | HttpClient.error.HttpClientError,
73
- Scope.Scope | HttpClient.client.Client.Default
72
+ Option.Option<HttpClientResponse.HttpClientResponse>,
73
+ NavigationError | HttpClientError.HttpClientError,
74
+ Scope.Scope | HttpClient.HttpClient.Service
74
75
  >
75
76
 
76
77
  readonly onFormData: <R = never, R2 = never>(
@@ -234,7 +235,7 @@ export type FormDataHandler<R, R2> = (
234
235
  event: FormDataEvent
235
236
  ) => Effect.Effect<
236
237
  Option.Option<
237
- Effect.Effect<Option.Option<HttpClient.response.ClientResponse>, RedirectError | CancelNavigation, R2>
238
+ Effect.Effect<Option.Option<HttpClientResponse.HttpClientResponse>, RedirectError | CancelNavigation, R2>
238
239
  >,
239
240
  RedirectError | CancelNavigation,
240
241
  R
@@ -283,8 +284,8 @@ export const FileSchemaFrom = Schema.Struct({
283
284
  */
284
285
  export type FileSchemaFrom = Schema.Schema.Encoded<typeof FileSchemaFrom>
285
286
 
286
- const decodeBase64 = ParseResult.decode(Schema.Base64)
287
- const encodeBase64 = ParseResult.encode(Schema.Base64)
287
+ const decodeBase64 = ParseResult.decode(Schema.Uint8ArrayFromBase64Url)
288
+ const encodeBase64 = ParseResult.encode(Schema.Uint8ArrayFromBase64Url)
288
289
 
289
290
  /**
290
291
  * @since 1.0.0
@@ -306,27 +307,28 @@ export const FileSchema = FileSchemaFrom.pipe(
306
307
  /**
307
308
  * @since 1.0.0
308
309
  */
309
- export const FormDataSchema = Schema.Record(Schema.String, Schema.Union(Schema.String, FileSchema)).pipe(
310
- Schema.transform(
311
- Schema.instanceOf(FormData),
312
- {
313
- decode: (formData) => {
314
- const data = new FormData()
315
-
316
- for (const [key, value] of Object.entries(formData)) {
317
- if (value instanceof File) {
318
- data.append(key, value, value.name)
319
- } else {
320
- data.append(key, value)
310
+ export const FormDataSchema = Schema.Record({ key: Schema.String, value: Schema.Union(Schema.String, FileSchema) })
311
+ .pipe(
312
+ Schema.transform(
313
+ Schema.instanceOf(FormData),
314
+ {
315
+ decode: (formData) => {
316
+ const data = new FormData()
317
+
318
+ for (const [key, value] of Object.entries(formData)) {
319
+ if (value instanceof File) {
320
+ data.append(key, value, value.name)
321
+ } else {
322
+ data.append(key, value)
323
+ }
321
324
  }
322
- }
323
325
 
324
- return data
325
- },
326
- encode: (formData) => Object.fromEntries(formData.entries())
327
- }
326
+ return data
327
+ },
328
+ encode: (formData) => Object.fromEntries(formData.entries())
329
+ }
330
+ )
328
331
  )
329
- )
330
332
 
331
333
  const optionNullable = { as: "Option", nullable: true } as const
332
334
 
@@ -334,10 +336,10 @@ const optionNullable = { as: "Option", nullable: true } as const
334
336
  * @since 1.0.0
335
337
  */
336
338
  export const FormInputSchema = Schema.Struct({
337
- name: Schema.optional(Schema.String, optionNullable),
338
- action: Schema.optional(Schema.String, optionNullable),
339
- method: Schema.optional(Schema.String, optionNullable),
340
- encoding: Schema.optional(Schema.String, optionNullable),
339
+ name: Schema.optionalWith(Schema.String, optionNullable),
340
+ action: Schema.optionalWith(Schema.String, optionNullable),
341
+ method: Schema.optionalWith(Schema.String, optionNullable),
342
+ encoding: Schema.optionalWith(Schema.String, optionNullable),
341
343
  data: FormDataSchema
342
344
  })
343
345
 
@@ -515,9 +517,9 @@ export function submit(
515
517
  data: FormData,
516
518
  formInput?: Simplify<Omit<FormInputFrom, "data">>
517
519
  ): Effect.Effect<
518
- Option.Option<HttpClient.response.ClientResponse>,
519
- NavigationError | HttpClient.error.HttpClientError,
520
- Navigation | HttpClient.client.Client.Default | Scope.Scope
520
+ Option.Option<HttpClientResponse.HttpClientResponse>,
521
+ NavigationError | HttpClientError.HttpClientError,
522
+ Navigation | HttpClient.HttpClient.Service | Scope.Scope
521
523
  > {
522
524
  return Navigation.withEffect((n) => n.submit(data, formInput))
523
525
  }
@@ -16,7 +16,7 @@ import type * as Layer from "effect/Layer"
16
16
  import type { Commit } from "../Layer.js"
17
17
  import type { BeforeNavigationEvent, Destination, NavigationEvent, Transition } from "../Navigation.js"
18
18
  import { Navigation, NavigationError } from "../Navigation.js"
19
- import type { ModelAndIntent } from "./shared.js"
19
+ import type { ModelAndIntent, PatchedState } from "./shared.js"
20
20
  import {
21
21
  getOriginalState,
22
22
  getUrl,
@@ -54,7 +54,9 @@ export const fromWindow: Layer.Layer<Navigation, never, Window> = Navigation.sco
54
54
  window.location.origin,
55
55
  getBaseHref(window),
56
56
  getRandomValues,
57
- hasNativeNavigation ? () => getNavigationState(window.navigation!) : undefined
57
+ hasNativeNavigation
58
+ ? () => getNavigationState(window.navigation!)
59
+ : undefined
58
60
  )
59
61
 
60
62
  return navigation
@@ -62,7 +64,11 @@ export const fromWindow: Layer.Layer<Navigation, never, Window> = Navigation.sco
62
64
  function handleHistoryEvent(event: HistoryEvent) {
63
65
  return Effect.gen(function*() {
64
66
  if (event._tag === "PushState") {
65
- return yield* navigation.navigate(event.url, {}, event.skipCommit)
67
+ return yield* navigation.navigate(
68
+ event.url,
69
+ {},
70
+ event.skipCommit
71
+ )
66
72
  } else if (event._tag === "ReplaceState") {
67
73
  if (Option.isSome(event.url)) {
68
74
  return yield* navigation.navigate(
@@ -75,19 +81,28 @@ export const fromWindow: Layer.Layer<Navigation, never, Window> = Navigation.sco
75
81
  }
76
82
  } else if (event._tag === "Traverse") {
77
83
  const { entries, index } = yield* modelAndIntent.state
78
- const toIndex = Math.min(Math.max(0, index + event.delta), entries.length - 1)
84
+ const toIndex = Math.min(
85
+ Math.max(0, index + event.delta),
86
+ entries.length - 1
87
+ )
79
88
  const to = entries[toIndex]
80
89
 
81
- return yield* navigation.traverseTo(to.key, {}, event.skipCommit)
90
+ const result = yield* navigation.traverseTo(
91
+ to.key,
92
+ {},
93
+ event.skipCommit
94
+ )
95
+
96
+ return result
82
97
  } else {
83
98
  yield* navigation.traverseTo(event.key, {}, event.skipCommit)
84
- return yield* navigation.updateCurrentEntry({ state: event.state })
99
+ return yield* navigation.updateCurrentEntry({
100
+ state: event.state
101
+ })
85
102
  }
86
103
  })
87
104
  }
88
- }).pipe(
89
- GetRandomValues.provide(getRandomValues)
90
- )
105
+ }).pipe(GetRandomValues.provide(getRandomValues))
91
106
  })
92
107
  )
93
108
 
@@ -114,10 +129,17 @@ function setupWithNavigation(
114
129
  return Effect.gen(function*() {
115
130
  const state = yield* RefSubject.fromEffect(
116
131
  Effect.sync((): NavigationState => getNavigationState(navigation)),
117
- { eq: Equivalence.make(Schema.typeSchema(Schema.typeSchema(NavigationState))) }
132
+ {
133
+ eq: Equivalence.make(
134
+ Schema.typeSchema(Schema.typeSchema(NavigationState))
135
+ )
136
+ }
118
137
  )
119
138
  const canGoBack = RefSubject.map(state, (s) => s.index > 0)
120
- const canGoForward = RefSubject.map(state, (s) => s.index < s.entries.length - 1)
139
+ const canGoForward = RefSubject.map(
140
+ state,
141
+ (s) => s.index < s.entries.length - 1
142
+ )
121
143
  const { beforeHandlers, formDataHandlers, handlers } = yield* makeHandlersState()
122
144
  const commit: Commit = (to: Destination, event: BeforeNavigationEvent) =>
123
145
  Effect.gen(function*(_) {
@@ -126,7 +148,14 @@ function setupWithNavigation(
126
148
 
127
149
  if (type === "push" || type === "replace") {
128
150
  yield* _(
129
- Effect.promise(() => navigation.navigate(url.toString(), { history: type, state, info }).committed),
151
+ Effect.promise(
152
+ () =>
153
+ navigation.navigate(url.toString(), {
154
+ history: type,
155
+ state,
156
+ info
157
+ }).committed
158
+ ),
130
159
  Effect.catchAllDefect((error) => Effect.fail(new NavigationError({ error })))
131
160
  )
132
161
  } else if (event.type === "reload") {
@@ -136,7 +165,9 @@ function setupWithNavigation(
136
165
  )
137
166
  } else {
138
167
  yield* _(
139
- Effect.promise(() => navigation.traverseTo(key, { info }).committed),
168
+ Effect.promise(
169
+ () => navigation.traverseTo(key, { info }).committed
170
+ ),
140
171
  Effect.catchAllDefect((error) => Effect.fail(new NavigationError({ error })))
141
172
  )
142
173
  }
@@ -218,7 +249,11 @@ function setupWithHistory(
218
249
  ): Effect.Effect<ModelAndIntent, never, GetRandomValues | Scope.Scope> {
219
250
  return Effect.gen(function*() {
220
251
  const { location } = window
221
- const { original: history, unpatch } = patchHistory(window, onEvent)
252
+ const {
253
+ getHistoryState,
254
+ original: history,
255
+ unpatch
256
+ } = patchHistory(window, onEvent)
222
257
 
223
258
  yield* Effect.addFinalizer(() => unpatch)
224
259
 
@@ -227,29 +262,65 @@ function setupWithHistory(
227
262
  Effect.map(
228
263
  makeDestination(
229
264
  new URL(location.href),
230
- history.state,
265
+ getHistoryState(),
231
266
  location.origin
232
267
  ),
233
- (destination): NavigationState => ({ entries: [destination], index: 0, transition: Option.none() })
268
+ (destination): NavigationState => ({
269
+ entries: [destination],
270
+ index: 0,
271
+ transition: Option.none()
272
+ })
234
273
  )
235
274
  ),
236
275
  { eq: Equivalence.make(Schema.typeSchema(NavigationState)) }
237
276
  )
238
277
  const canGoBack = RefSubject.map(state, (s) => s.index > 0)
239
- const canGoForward = RefSubject.map(state, (s) => s.index < s.entries.length - 1)
278
+ const canGoForward = RefSubject.map(
279
+ state,
280
+ (s) => s.index < s.entries.length - 1
281
+ )
240
282
  const { beforeHandlers, formDataHandlers, handlers } = yield* makeHandlersState()
241
- const commit: Commit = ({ id, key, state, url }: Destination, event: BeforeNavigationEvent) =>
283
+ const commit: Commit = (
284
+ { id, key, state, url }: Destination,
285
+ event: BeforeNavigationEvent
286
+ ) =>
242
287
  Effect.sync(() => {
243
288
  const { type } = event
244
289
 
245
290
  if (type === "push") {
246
- history.pushState({ id, key, originalHistoryState: state }, "", url)
291
+ history.pushState(
292
+ {
293
+ __typed__navigation__id__: id,
294
+ __typed__navigation__key__: key,
295
+ __typed__navigation__state__: state
296
+ },
297
+ "",
298
+ url
299
+ )
247
300
  } else if (type === "replace") {
248
- history.replaceState({ id, key, originalHistoryState: state }, "", url)
301
+ history.replaceState(
302
+ {
303
+ __typed__navigation__id__: id,
304
+ __typed__navigation__key__: key,
305
+ __typed__navigation__state__: state
306
+ },
307
+ "",
308
+ url
309
+ )
249
310
  } else if (event.type === "reload") {
250
311
  location.reload()
251
312
  } else {
252
313
  history.go(event.delta)
314
+
315
+ history.replaceState(
316
+ {
317
+ __typed__navigation__id__: id,
318
+ __typed__navigation__key__: key,
319
+ __typed__navigation__state__: state
320
+ },
321
+ "",
322
+ window.location.href
323
+ )
253
324
  }
254
325
  })
255
326
 
@@ -265,16 +336,36 @@ function setupWithHistory(
265
336
  })
266
337
  }
267
338
 
268
- type HistoryEvent = PushStateEvent | ReplaceStateEvent | TraverseEvent | TraverseToEvent
269
-
270
- type PushStateEvent = { _tag: "PushState"; state: unknown; url: URL; skipCommit: boolean }
271
- type ReplaceStateEvent = { _tag: "ReplaceState"; state: unknown; url: Option.Option<URL>; skipCommit: boolean }
339
+ type HistoryEvent =
340
+ | PushStateEvent
341
+ | ReplaceStateEvent
342
+ | TraverseEvent
343
+ | TraverseToEvent
344
+
345
+ type PushStateEvent = {
346
+ _tag: "PushState"
347
+ state: unknown
348
+ url: URL
349
+ skipCommit: boolean
350
+ }
351
+ type ReplaceStateEvent = {
352
+ _tag: "ReplaceState"
353
+ state: unknown
354
+ url: Option.Option<URL>
355
+ skipCommit: boolean
356
+ }
272
357
  type TraverseEvent = { _tag: "Traverse"; delta: number; skipCommit: boolean }
273
- type TraverseToEvent = { _tag: "TraverseTo"; key: Uuid; state: unknown; skipCommit: boolean }
358
+ type TraverseToEvent = {
359
+ _tag: "TraverseTo"
360
+ key: Uuid
361
+ state: unknown
362
+ skipCommit: boolean
363
+ }
274
364
 
275
365
  function patchHistory(window: Window, onEvent: (event: HistoryEvent) => void) {
276
366
  const { history, location } = window
277
- const stateDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(history), "state")
367
+ const stateDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(history), "state") ||
368
+ Object.getOwnPropertyDescriptor(history, "state")
278
369
 
279
370
  const methods = {
280
371
  pushState: history.pushState.bind(history),
@@ -283,7 +374,9 @@ function patchHistory(window: Window, onEvent: (event: HistoryEvent) => void) {
283
374
  back: history.back.bind(history),
284
375
  forward: history.forward.bind(history)
285
376
  }
286
- const getState = stateDescriptor?.get?.bind(history)
377
+ const getStateDescriptor = stateDescriptor?.get?.bind(history)
378
+
379
+ const getHistoryState = () => getStateDescriptor?.()
287
380
 
288
381
  const original: History = {
289
382
  get length() {
@@ -296,30 +389,32 @@ function patchHistory(window: Window, onEvent: (event: HistoryEvent) => void) {
296
389
  history.scrollRestoration = mode
297
390
  },
298
391
  get state() {
299
- return getState?.() ?? history.state
392
+ return getHistoryState()
300
393
  },
301
394
  ...methods,
302
395
  pushState(data, _, url) {
303
- if (!stateDescriptor) {
304
- ;(history as any).state = data
305
- }
306
-
307
- return methods.pushState(data, _, url)
396
+ return methods.pushState(data, _, url?.toString())
308
397
  },
309
398
  replaceState(data, _, url) {
310
- if (!stateDescriptor) {
311
- ;(history as any).state = data
312
- }
313
-
314
- return methods.replaceState(data, _, url)
399
+ return methods.replaceState(data, _, url?.toString())
315
400
  }
316
401
  }
317
402
 
318
403
  history.pushState = (state, _, url) => {
319
404
  if (url) {
320
- onEvent({ _tag: "PushState", state, url: getUrl(location.origin, url), skipCommit: false })
405
+ onEvent({
406
+ _tag: "PushState",
407
+ state,
408
+ url: getUrl(location.origin, url),
409
+ skipCommit: false
410
+ })
321
411
  } else {
322
- onEvent({ _tag: "ReplaceState", state, url: Option.none(), skipCommit: false })
412
+ onEvent({
413
+ _tag: "ReplaceState",
414
+ state,
415
+ url: Option.none(),
416
+ skipCommit: false
417
+ })
323
418
  }
324
419
  }
325
420
  history.replaceState = (state, _, url) => {
@@ -342,30 +437,32 @@ function patchHistory(window: Window, onEvent: (event: HistoryEvent) => void) {
342
437
  onEvent({ _tag: "Traverse", delta: 1, skipCommit: false })
343
438
  }
344
439
 
345
- // In a proper browser this will allow patching to hide the id/key's associated with the state
346
- if (stateDescriptor) {
347
- try {
348
- Object.defineProperty(history, "state", {
349
- get() {
350
- return getOriginalState(stateDescriptor.get!.call(history))
351
- }
352
- })
353
- } catch {
354
- // We tried, but it didn't work
355
- }
356
- }
357
-
358
440
  const onHashChange = (ev: HashChangeEvent) => {
359
- onEvent({ _tag: "ReplaceState", state: history.state, url: Option.some(new URL(ev.newURL)), skipCommit: false })
441
+ onEvent({
442
+ _tag: "ReplaceState",
443
+ state: history.state,
444
+ url: Option.some(new URL(ev.newURL)),
445
+ skipCommit: false
446
+ })
360
447
  }
361
448
 
362
449
  window.addEventListener("hashchange", onHashChange, { capture: true })
363
450
 
364
451
  const onPopState = (ev: PopStateEvent) => {
365
452
  if (isPatchedState(ev.state)) {
366
- onEvent({ _tag: "TraverseTo", key: ev.state.key, state: ev.state.originalHistoryState, skipCommit: true })
453
+ onEvent({
454
+ _tag: "TraverseTo",
455
+ key: ev.state.__typed__navigation__key__,
456
+ state: ev.state.__typed__navigation__state__,
457
+ skipCommit: true
458
+ })
367
459
  } else {
368
- onEvent({ _tag: "ReplaceState", state: ev.state, url: Option.some(new URL(location.href)), skipCommit: true })
460
+ onEvent({
461
+ _tag: "ReplaceState",
462
+ state: ev.state,
463
+ url: Option.some(new URL(location.href)),
464
+ skipCommit: true
465
+ })
369
466
  }
370
467
  }
371
468
 
@@ -390,7 +487,39 @@ function patchHistory(window: Window, onEvent: (event: HistoryEvent) => void) {
390
487
  window.removeEventListener("popstate", onPopState)
391
488
  })
392
489
 
490
+ Object.defineProperty(history, "state", {
491
+ get() {
492
+ console.log("here")
493
+ return getOriginalState(getStateDescriptor?.() ?? history.state)
494
+ },
495
+ set(value) {
496
+ const { __typed__navigation__id__, __typed__navigation__key__ } = getStateDescriptor?.() ?? original.state
497
+
498
+ if (isPatchedState(value)) {
499
+ // The setter is not actually modifying the history.state
500
+ // We need to call the original replaceState to update the actual state
501
+ original.replaceState.call(history, value, "", location.href)
502
+ } else {
503
+ // The setter is not actually modifying the history.state
504
+ // We need to call the original replaceState to update the actual state
505
+ original.replaceState.call(
506
+ history,
507
+ {
508
+ __typed__navigation__id__,
509
+ __typed__navigation__key__,
510
+ __typed__navigation__state__: value
511
+ } satisfies PatchedState,
512
+ "",
513
+ location.href
514
+ )
515
+ }
516
+
517
+ return value
518
+ }
519
+ })
520
+
393
521
  return {
522
+ getHistoryState,
394
523
  original,
395
524
  patched: history,
396
525
  unpatch
@@ -400,21 +529,33 @@ function patchHistory(window: Window, onEvent: (event: HistoryEvent) => void) {
400
529
  type ScopedRuntime<R> = {
401
530
  readonly runtime: Runtime.Runtime<R | Scope.Scope>
402
531
  readonly scope: Scope.Scope
403
- readonly run: <E, A>(effect: Effect.Effect<A, E, R | Scope.Scope>) => Fiber.RuntimeFiber<A, E>
404
- readonly runPromise: <E, A>(effect: Effect.Effect<A, E, R | Scope.Scope>) => Promise<A>
532
+ readonly run: <E, A>(
533
+ effect: Effect.Effect<A, E, R | Scope.Scope>
534
+ ) => Fiber.RuntimeFiber<A, E>
535
+ readonly runPromise: <E, A>(
536
+ effect: Effect.Effect<A, E, R | Scope.Scope>
537
+ ) => Promise<A>
405
538
  }
406
539
 
407
- function scopedRuntime<R>(): Effect.Effect<ScopedRuntime<R>, never, R | Scope.Scope> {
540
+ function scopedRuntime<R>(): Effect.Effect<
541
+ ScopedRuntime<R>,
542
+ never,
543
+ R | Scope.Scope
544
+ > {
408
545
  return Effect.map(Effect.runtime<R | Scope.Scope>(), (runtime) => {
409
546
  const scope = Context.get(runtime.context, Scope.Scope)
410
547
  const runFork = Runtime.runFork(runtime)
411
- const runPromise = <E, A>(effect: Effect.Effect<A, E, R | Scope.Scope>): Promise<A> =>
548
+ const runPromise = <E, A>(
549
+ effect: Effect.Effect<A, E, R | Scope.Scope>
550
+ ): Promise<A> =>
412
551
  new Promise((resolve, reject) => {
413
552
  const fiber = runFork(effect, { scope })
414
- fiber.addObserver(Exit.match({
415
- onFailure: (cause) => reject(Runtime.makeFiberFailure(cause)),
416
- onSuccess: resolve
417
- }))
553
+ fiber.addObserver(
554
+ Exit.match({
555
+ onFailure: (cause) => reject(Runtime.makeFiberFailure(cause)),
556
+ onSuccess: resolve
557
+ })
558
+ )
418
559
  })
419
560
 
420
561
  return {